diff --git a/.cursor/plans/microservices_infrastructure_setup_c72c57a3.plan.md b/.cursor/plans/microservices_infrastructure_setup_c72c57a3.plan.md new file mode 100644 index 000000000..cacc925c9 --- /dev/null +++ b/.cursor/plans/microservices_infrastructure_setup_c72c57a3.plan.md @@ -0,0 +1,179 @@ +--- +name: Microservices Infrastructure Setup +overview: Set up microservices infrastructure (API Gateway, shared patterns, single shared database, inter-service communication) before extracting the Activity Service as the next microservice. +todos: + - id: api-gateway + content: Create API Gateway (Spring Cloud Gateway) with JWT validation, routing, rate limiting, CORS + status: completed + - id: shared-lib + content: Create shared library or copy shared DTOs/utils to services + status: completed + - id: shared-db + content: Use single shared MySQL database for monolith and all services + status: completed + - id: inter-service-comm + content: Set up Feign clients + Resilience4j for inter-service communication + status: completed + - id: redis-pubsub + content: Implement Redis Pub/Sub for cross-service async events + status: completed + - id: extract-activity + content: Extract Activity Service (entities, repos, services, controllers, DTOs) + status: completed + - id: gateway-routes + content: Update gateway routes for activity-service + status: completed + - id: health-logging + content: Add health checks, structured logging, and distributed tracing across services + status: completed + - id: extract-chat + content: Extract Chat Service (chat entities, REST + optional WebSocket, Feign to monolith/activity) + status: completed + - id: monolith-cleanup + content: Remove activity/chat domain from monolith, use Feign clients and spawn-common DTOs + status: completed +isProject: false +--- + +# Microservices Infrastructure & Next Service Extraction Plan + +## Current State + +- Auth service extracted at `services/auth-service/` (port 8081) +- Main monolith at `src/main/java/` with modular package structure +- Both share the same MySQL database +- No API Gateway, no inter-service communication, no Docker +- Deploying on Railway + +## Phase 1: Infrastructure Foundation (Weeks 1-3) + +### 1A. API Gateway (Spring Cloud Gateway) + +Create `gateway/api-gateway/` as a new Spring Boot application: + +- **JWT validation** as a global filter -- the gateway validates tokens so downstream services don't each need to. It adds `X-User-Id` header to forwarded requests. +- **Route configuration** mapping `/api/v1/auth/`** to auth-service (port 8081), everything else to the monolith (port 8080) for now. As services are extracted, new routes get added. +- **Rate limiting** via Redis (reuse existing Bucket4j or use Spring Cloud Gateway's built-in Redis rate limiter). +- **CORS handling** centralized at the gateway level. +- **Health check** endpoint at `/actuator/health`. +- Gateway runs on port **8090** (or configurable). + +Key files to create: + +- `gateway/api-gateway/pom.xml` -- Spring Cloud Gateway + JWT dependencies +- `gateway/api-gateway/src/main/java/.../GatewayApplication.java` +- `gateway/api-gateway/src/main/java/.../filter/JwtAuthFilter.java` +- `gateway/api-gateway/src/main/resources/application.yml` -- route definitions + +### 1B. Shared Library Pattern + +Since services are independent Maven projects, create a lightweight shared module at `shared/spawn-common/`: + +- **Common DTOs** for inter-service communication (e.g., `UserSummaryDTO` that services exchange) +- **JWT utilities** (token validation logic shared between gateway and services) +- **Common exception types** + +Publish via `mvn install` locally, or as a GitHub Package for Railway builds. Each service adds it as a Maven dependency. + +Alternatively, for maximum simplicity: just copy the ~5-10 shared classes into each service. Easier to start with, refactor to shared lib later. + +### 1C. Single Shared Database + +All backends (monolith and microservices) use **one MySQL database**: + +- No new databases per service — monolith, auth-service, and future services (e.g. activity-service) all connect to the same MySQL instance/schema. +- Auth service and activity service use the same datasource URL as the monolith; each service only touches the tables it owns (e.g. auth: `email_verification`, `user_id_external_id_map`; activity: `activity`, `activity_type`, etc.). +- User data for login/registration can stay as direct DB access or REST to monolith, depending on preference; shared DB allows direct access if desired. +- Flyway migrations can live in the monolith (or a single migration module) so schema changes are applied once. + +### 1D. Inter-Service Communication + +Set up the pattern for services to call each other: + +- **Synchronous (Feign/RestTemplate):** Auth service calls monolith to look up users. Activity service (later) calls monolith for user data. +- **Async events (Redis Pub/Sub):** Replace Spring `ApplicationEventPublisher` with Redis Pub/Sub for cross-service events (e.g., "user registered" event from auth -> monolith notification system). Start simple -- only add Redis Pub/Sub for events that actually need to cross service boundaries. +- **Circuit breakers (Resilience4j):** Wrap Feign clients so a downstream service outage doesn't cascade. + +--- + +## Phase 2: Extract Activity Service (Weeks 4-8) + +Once infrastructure is in place, extract the Activity domain: + +### What moves to `services/activity-service/`: + +- **Entities:** `Activity`, `ActivityType`, `ActivityUser`, `Location` (from [activity/internal/domain/](src/main/java/com/danielagapov/spawn/activity/internal/domain/)) +- **Repositories:** Activity, ActivityType, ActivityUser, Location repositories +- **Services:** `ActivityService`, `ActivityTypeService`, `LocationService`, `CalendarService`, `ActivityParticipationService` +- **Controllers:** `ActivityController`, `ActivityTypeController`, `CalendarController` +- **DTOs:** All activity-related DTOs + +### Cross-cutting concerns: + +- Activity service needs **user data** (creator info, participant info) -- use Feign client to call monolith's `/api/v1/users/{id}` endpoint +- Activity service needs **friend data** (for invite validation) -- Feign client to monolith's social endpoints +- Activity creation triggers **notification events** -- publish to Redis Pub/Sub, monolith's notification module subscribes +- **Caching:** Activity service gets its own Redis namespace (`activity:`*) + +### Database: + +- **Shared database:** Activity service connects to the same MySQL as the monolith. Activity, ActivityType, ActivityUser, Location tables remain in that single schema; Flyway migrations (if used) stay in the monolith or a single place. + +### Gateway update: + +- Add route: `/api/v1/activities/`** and `/api/v1/activity-types/`** -> activity-service + +--- + +## Phase 3: Polish & Prepare for Chat (Weeks 9-10) + +- Add health checks to all services (Spring Boot Actuator) +- Set up structured logging (JSON format) across services +- Add distributed tracing headers (`X-Trace-Id` propagation) +- Document API contracts between services +- Monitor Railway metrics, tune resource allocation +- Evaluate: proceed to Chat Service extraction? + +--- + +## Phase 4: Extract Chat Service (Next) + +- **Scope:** Move chat domain from monolith to `services/chat-service/` (port 8083). +- **What moves:** Entities `ChatMessage`, `ChatMessageLikes`; repositories; `ChatMessageService`, `IChatMessageService`; `ChatMessageController`; DTOs (`CreateChatMessageDTO`, `FullActivityChatMessageDTO`, etc.). +- **Cross-cutting:** Chat service needs activity membership checks — Feign to activity-service or monolith. Monolith currently exposes `/api/v1/chat-messages/by-activity/{activityId}` for activity-service; after extraction, chat-service owns that API and activity-service calls chat-service via Feign. +- **Gateway:** Add route `/api/v1/chat-messages/`** → chat-service. +- **Optional:** WebSocket support for real-time messaging (see `docs/microservices/MICROSERVICES_IMPLEMENTATION_PLAN.md`). +- **Database:** Shared MySQL; chat tables remain in the same schema. + +--- + +## Summary of Deliverables + +- `gateway/api-gateway/` -- Spring Cloud Gateway with JWT validation, routing, rate limiting +- `shared/spawn-common/` -- shared DTOs (activity, chat, user) for Feign clients and inter-service contracts +- `services/auth-service/` -- uses shared database; Feign client for user lookup as needed +- `services/activity-service/` -- microservice for activity domain (shared database) +- `services/chat-service/` -- microservice for chat domain (shared database) +- Updated monolith: activity/chat domains removed; uses ActivityServiceClient, ChatServiceClient; DTOs from spawn-common +- Updated gateway routes +- Distributed tracing: `X-Trace-Id` propagation (gateway), MDC in monolith; see `docs/microservices/DISTRIBUTED_TRACING.md` + +## Monolith Cleanup (Completed) + +- Removed `src/main/java/.../activity/` package (controllers, services, repos, entities, DTOs) +- Removed activity mappers (ActivityMapper, ActivityTypeMapper, LocationMapper) +- Removed activity notification events (ActivityInviteNotificationEvent, etc.) — activity-service has its own +- Replaced IActivityService/IActivityTypeService usage with ActivityServiceClient (ReportContentService, CacheService, ShareLinkController, NewCommentEventSubscriber) +- Replaced ICalendarService with ActivityServiceClient and ActivityExpirationService with ActivityExpirationUtil +- ActivityTypeInitializer and ActivityTypeEventListener now call activity-service via Feign +- Added MinimalFriendDTO to spawn-common for ActivityTypeDTO + +## Key Technical Decisions + +- **Independent Maven projects** per service (no parent POM) +- **No Docker** for local dev -- develop against Railway +- **Spring Cloud Gateway** for API Gateway +- **Feign + Resilience4j** for synchronous inter-service calls +- **Redis Pub/Sub** for async cross-service events +- **Single shared MySQL database** for monolith and all services on Railway + diff --git a/docs/microservices/DISTRIBUTED_TRACING.md b/docs/microservices/DISTRIBUTED_TRACING.md new file mode 100644 index 000000000..f4cf4d650 --- /dev/null +++ b/docs/microservices/DISTRIBUTED_TRACING.md @@ -0,0 +1,29 @@ +# Distributed Tracing (X-Trace-Id) + +Request tracing is implemented so logs across the gateway and backend services can be correlated by a single ID. + +## How it works + +1. **API Gateway** (`gateway/api-gateway`): + - **TraceIdFilter** runs first on every request. + - If the client sends an `X-Trace-Id` header, it is reused; otherwise a new UUID is generated. + - The chosen trace ID is added to the request forwarded to downstream services and echoed in the response (`X-Trace-Id`). + +2. **Monolith** (`src/main/java`): + - **TraceIdMdcFilter** reads `X-Trace-Id` from the request (or generates one for direct calls) and puts it in SLF4J MDC under the key `traceId`. + - Logback is configured so log lines include `traceId=...`. This allows log aggregation tools to correlate all log entries for a given request. + +3. **Other services** (auth-service, activity-service): + - When called via the gateway, they receive `X-Trace-Id` on the request. + - To include trace ID in their logs, add a similar filter that reads `X-Trace-Id` and puts it in MDC, and add `traceId=%X{traceId:-}` (or equivalent) to the log pattern. + +## Client usage + +Clients can send an `X-Trace-Id` header (e.g. a UUID they generate) when making requests. The same value will be returned in the response and used for all downstream calls, making it easy to correlate client-side logs with backend logs. + +## Feign / outbound calls + +When a service (e.g. activity-service) calls another service via Feign, the gateway is not in the path. To propagate the trace ID: + +- Add a Feign `RequestInterceptor` that reads the current MDC `traceId` (or request attribute) and sets the `X-Trace-Id` header on the outgoing request. +- Ensure the receiving service has a filter that puts `X-Trace-Id` into MDC (as in the monolith). diff --git a/gateway/api-gateway/.mvn/maven.config b/gateway/api-gateway/.mvn/maven.config new file mode 100644 index 000000000..49cceca31 --- /dev/null +++ b/gateway/api-gateway/.mvn/maven.config @@ -0,0 +1,2 @@ +# Maven configuration options +# Add Maven command-line options here, one per line diff --git a/gateway/api-gateway/.mvn/wrapper/maven-wrapper.properties b/gateway/api-gateway/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..d58dfb70b --- /dev/null +++ b/gateway/api-gateway/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/gateway/api-gateway/mvnw b/gateway/api-gateway/mvnw new file mode 100755 index 000000000..19529ddf8 --- /dev/null +++ b/gateway/api-gateway/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/gateway/api-gateway/pom.xml b/gateway/api-gateway/pom.xml new file mode 100644 index 000000000..6798e4c57 --- /dev/null +++ b/gateway/api-gateway/pom.xml @@ -0,0 +1,104 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + com.danielagapov + spawn-api-gateway + 0.0.1-SNAPSHOT + spawn-api-gateway + Spawn API Gateway - Routes, JWT validation, rate limiting, CORS + + 17 + 2023.0.4 + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + org.springframework.cloud + spring-cloud-starter-gateway + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-data-redis-reactive + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + + + + + io.github.cdimascio + dotenv-java + 3.0.2 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + central + https://repo.maven.apache.org/maven2 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + + + + + diff --git a/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/GatewayApplication.java b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/GatewayApplication.java new file mode 100644 index 000000000..7fbb8d601 --- /dev/null +++ b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/GatewayApplication.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.gateway; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class GatewayApplication { + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/CorsConfig.java b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/CorsConfig.java new file mode 100644 index 000000000..9ab8dbc0b --- /dev/null +++ b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/CorsConfig.java @@ -0,0 +1,65 @@ +package com.danielagapov.spawn.gateway.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.reactive.CorsWebFilter; +import org.springframework.web.cors.reactive.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +/** + * Centralised CORS configuration for the API Gateway. + *

+ * All CORS handling happens at the gateway level so downstream services + * do not need their own CORS config (they only accept internal traffic). + */ +@Configuration +public class CorsConfig { + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @Bean + public CorsWebFilter corsWebFilter() { + CorsConfiguration config = new CorsConfiguration(); + + boolean isProduction = "prod".equals(activeProfile) || "production".equals(activeProfile); + + if (isProduction) { + config.setAllowedOrigins(List.of( + "https://getspawn.com", + "https://admin.getspawn.com", + "https://getspawn.com/admin" + )); + } else { + config.setAllowedOrigins(List.of( + "https://getspawn.com", + "https://admin.getspawn.com", + "https://getspawn.com/admin", + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8081", + "http://localhost:8090", + "http://localhost:4200", + "http://localhost:8100", + "http://127.0.0.1:3000", + "http://127.0.0.1:8080", + "capacitor://localhost" + )); + } + + config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + config.setAllowedHeaders(List.of("Authorization", "X-Refresh-Token", "Content-Type", "Accept")); + config.setExposedHeaders(List.of("Authorization", "X-Refresh-Token")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + + return new CorsWebFilter(source); + } +} diff --git a/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/RateLimiterConfig.java b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/RateLimiterConfig.java new file mode 100644 index 000000000..767afc8ac --- /dev/null +++ b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/config/RateLimiterConfig.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.gateway.config; + +import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import reactor.core.publisher.Mono; + +/** + * Rate limiter configuration for the API Gateway. + *

+ * Uses the client IP address (from X-Forwarded-For or remote address) as the + * rate-limit key, so each client is independently rate-limited. + */ +@Configuration +public class RateLimiterConfig { + + @Bean + public KeyResolver ipKeyResolver() { + return exchange -> { + String forwardedFor = exchange.getRequest().getHeaders().getFirst("X-Forwarded-For"); + if (forwardedFor != null && !forwardedFor.isEmpty()) { + return Mono.just(forwardedFor.split(",")[0].trim()); + } + String remoteAddr = exchange.getRequest().getRemoteAddress() != null + ? exchange.getRequest().getRemoteAddress().getAddress().getHostAddress() + : "unknown"; + return Mono.just(remoteAddr); + }; + } +} diff --git a/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/JwtAuthFilter.java b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/JwtAuthFilter.java new file mode 100644 index 000000000..931736b13 --- /dev/null +++ b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/JwtAuthFilter.java @@ -0,0 +1,209 @@ +package com.danielagapov.spawn.gateway.filter; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Set; + +/** + * Global JWT authentication filter for the API Gateway. + *

+ * Validates JWT tokens on incoming requests and adds an X-User-Id header + * (containing the token subject) to forwarded requests so downstream services + * can identify the authenticated user without re-validating the token. + *

+ * Requests to whitelisted paths (e.g. auth endpoints) bypass validation. + */ +@Component +public class JwtAuthFilter implements GlobalFilter, Ordered { + + private static final Logger log = LoggerFactory.getLogger(JwtAuthFilter.class); + + /** + * Paths that do not require JWT authentication. + * Auth endpoints are open so users can sign in / register / refresh tokens. + * Actuator health is open for load-balancer probes. + */ + private static final List OPEN_PATHS = List.of( + "/api/v1/auth/sign-in", + "/api/v1/auth/login", + "/api/v1/auth/register/verification/send", + "/api/v1/auth/register/oauth", + "/api/v1/auth/register/verification/check", + "/api/v1/auth/refresh-token", + "/actuator/health" + ); + + /** + * Path prefixes that are open without JWT validation. + * These cover endpoints with dynamic path segments (e.g. UUIDs). + */ + private static final List OPEN_PATH_PREFIXES = List.of( + "/api/v1/auth/accept-tos/", + "/api/v1/auth/complete-contact-import/", + // Public share-link and beta-access endpoints from the monolith + "/api/v1/share-links/", + "/api/v1/beta-access/" + ); + + private final String signingSecret; + + public JwtAuthFilter(@Value("${jwt.signing-secret:#{null}}") String configuredSecret) { + // Priority: 1) Spring property / env, 2) SIGNING_SECRET env var, 3) .env file + this.signingSecret = resolveSigningSecret(configuredSecret); + } + + private static String resolveSigningSecret(String configuredSecret) { + if (configuredSecret != null && !configuredSecret.isEmpty()) { + return configuredSecret; + } + String envSecret = System.getenv("SIGNING_SECRET"); + if (envSecret != null && !envSecret.isEmpty()) { + return envSecret; + } + try { + var dotenv = io.github.cdimascio.dotenv.Dotenv.configure().ignoreIfMissing().load(); + return dotenv.get("SIGNING_SECRET"); + } catch (Exception e) { + log.warn("Could not load .env file: {}", e.getMessage()); + return null; + } + } + + @PostConstruct + void validate() { + if (signingSecret == null || signingSecret.isBlank()) { + log.error("JWT signing secret is not configured! Set jwt.signing-secret or SIGNING_SECRET env var."); + } else { + log.info("JWT signing secret loaded successfully."); + } + } + + @Override + public int getOrder() { + // Run early so auth is checked before any other filters + return -1; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String path = request.getURI().getPath(); + + // Skip JWT validation for open/whitelisted paths + if (isOpenPath(path)) { + return chain.filter(exchange); + } + + // Extract Bearer token from Authorization header + String authHeader = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + return onUnauthorized(exchange, "Missing or invalid Authorization header"); + } + + String token = authHeader.substring(7); + + // Validate the token + Claims claims; + try { + claims = extractAllClaims(token); + } catch (ExpiredJwtException e) { + return onUnauthorized(exchange, "Token expired"); + } catch (JwtException e) { + return onUnauthorized(exchange, "Invalid token: " + e.getMessage()); + } catch (Exception e) { + log.error("Unexpected error validating JWT: {}", e.getMessage()); + return onUnauthorized(exchange, "Token validation failed"); + } + + // Validate issuer and audience + if (!"spawn-backend".equals(claims.getIssuer())) { + return onUnauthorized(exchange, "Invalid token issuer"); + } + Set audiences = claims.getAudience(); + if (audiences == null || !audiences.contains("spawn-app")) { + return onUnauthorized(exchange, "Invalid token audience"); + } + + // Validate token type is ACCESS + String tokenType = (String) claims.get("type"); + if (!"ACCESS".equals(tokenType)) { + return onUnauthorized(exchange, "Token is not an access token"); + } + + // Token is valid -- inject X-User-Id header for downstream services + String subject = claims.getSubject(); + ServerHttpRequest mutatedRequest = request.mutate() + .header("X-User-Id", subject) + .build(); + + return chain.filter(exchange.mutate().request(mutatedRequest).build()); + } + + /* ----------------------------- Helpers ----------------------------- */ + + private boolean isOpenPath(String path) { + for (String open : OPEN_PATHS) { + if (path.equals(open)) { + return true; + } + } + for (String prefix : OPEN_PATH_PREFIXES) { + if (path.startsWith(prefix)) { + return true; + } + } + return false; + } + + private Claims extractAllClaims(String token) { + return Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private SecretKey getKey() { + byte[] keyBytes = Decoders.BASE64.decode(signingSecret); + return Keys.hmacShaKeyFor(keyBytes); + } + + private Mono onUnauthorized(ServerWebExchange exchange, String reason) { + log.warn("JWT auth failed for {} {}: {}", + exchange.getRequest().getMethod(), + exchange.getRequest().getURI().getPath(), + reason); + + ServerHttpResponse response = exchange.getResponse(); + response.setStatusCode(HttpStatus.UNAUTHORIZED); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + String body = "{\"error\":\"Authentication required\",\"detail\":\"" + reason + "\"}"; + DataBuffer buffer = response.bufferFactory().wrap(body.getBytes(StandardCharsets.UTF_8)); + return response.writeWith(Mono.just(buffer)); + } +} diff --git a/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/TraceIdFilter.java b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/TraceIdFilter.java new file mode 100644 index 000000000..9a4853955 --- /dev/null +++ b/gateway/api-gateway/src/main/java/com/danielagapov/spawn/gateway/filter/TraceIdFilter.java @@ -0,0 +1,63 @@ +package com.danielagapov.spawn.gateway.filter; + +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.http.server.reactive.ServerHttpResponseDecorator; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** + * Global filter that propagates or generates a request trace ID for distributed tracing. + *

+ * If the client sends {@code X-Trace-Id}, it is forwarded to downstream services and echoed + * in the response. Otherwise a new UUID is generated. Downstream services can read + * {@code X-Trace-Id} from the request and include it in their logs for correlation. + */ +@Component +public class TraceIdFilter implements GlobalFilter, Ordered { + + public static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + public int getOrder() { + // Run before JWT filter so trace ID is set for all requests including auth failures + return -2; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + ServerHttpRequest request = exchange.getRequest(); + String traceId = request.getHeaders().getFirst(TRACE_ID_HEADER); + if (traceId == null || traceId.isBlank()) { + traceId = UUID.randomUUID().toString(); + } + + final String finalTraceId = traceId; + ServerHttpRequest mutatedRequest = request.mutate() + .header(TRACE_ID_HEADER, finalTraceId) + .build(); + + ServerHttpResponse originalResponse = exchange.getResponse(); + ServerHttpResponseDecorator responseDecorator = new ServerHttpResponseDecorator(originalResponse) { + @Override + public org.springframework.http.HttpHeaders getHeaders() { + org.springframework.http.HttpHeaders headers = super.getHeaders(); + if (!headers.containsKey(TRACE_ID_HEADER)) { + headers.add(TRACE_ID_HEADER, finalTraceId); + } + return headers; + } + }; + + return chain.filter(exchange.mutate() + .request(mutatedRequest) + .response(responseDecorator) + .build()); + } +} diff --git a/gateway/api-gateway/src/main/resources/application.yml b/gateway/api-gateway/src/main/resources/application.yml new file mode 100644 index 000000000..171fd04fb --- /dev/null +++ b/gateway/api-gateway/src/main/resources/application.yml @@ -0,0 +1,80 @@ +server: + port: ${GATEWAY_PORT:8090} + +spring: + application: + name: spawn-api-gateway + + # ── Redis (used for rate limiting) ── + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + + cloud: + gateway: + # ── Global defaults ── + default-filters: + - name: RequestRateLimiter + args: + redis-rate-limiter.replenishRate: 20 # requests per second + redis-rate-limiter.burstCapacity: 40 # max burst + redis-rate-limiter.requestedTokens: 1 + key-resolver: "#{@ipKeyResolver}" + + # ── Route definitions ── + routes: + # Auth Service (port 8081) + - id: auth-service + uri: ${AUTH_SERVICE_URL:http://localhost:8081} + predicates: + - Path=/api/v1/auth/** + filters: + - StripPrefix=0 + + # Activity Service (port 8082) + - id: activity-service + uri: ${ACTIVITY_SERVICE_URL:http://localhost:8082} + predicates: + - Path=/api/v1/activities/**,/api/v1/users/*/activity-types/** + filters: + - StripPrefix=0 + + # Chat Service (port 8083) + - id: chat-service + uri: ${CHAT_SERVICE_URL:http://localhost:8083} + predicates: + - Path=/api/v1/chat-messages/** + filters: + - StripPrefix=0 + + # Monolith -- catch-all for everything else (port 8080) + - id: monolith + uri: ${MONOLITH_URL:http://localhost:8080} + predicates: + - Path=/api/v1/** + filters: + - StripPrefix=0 + +# ── JWT signing secret ── +jwt: + signing-secret: ${SIGNING_SECRET:} + +# ── Actuator (health check) ── +management: + endpoints: + web: + exposure: + include: health,info,gateway + endpoint: + health: + show-details: always + gateway: + enabled: true + +# ── Logging ── +logging: + level: + com.danielagapov.spawn.gateway: INFO + org.springframework.cloud.gateway: INFO diff --git a/gateway/api-gateway/src/main/resources/logback-spring.xml b/gateway/api-gateway/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..84c878bea --- /dev/null +++ b/gateway/api-gateway/src/main/resources/logback-spring.xml @@ -0,0 +1,17 @@ + + + + + + %d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} %-5level [%logger{36}] %msg%n + UTF-8 + + + + + + + + + + diff --git a/pom.xml b/pom.xml index 75d114010..b612d92ca 100644 --- a/pom.xml +++ b/pom.xml @@ -28,9 +28,32 @@ 17 - 1.18.36 + 1.18.42 + 2023.0.4 + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + com.danielagapov + spawn-common + 0.0.1-SNAPSHOT + + + + org.springframework.cloud + spring-cloud-starter-openfeign + org.springframework.boot spring-boot-starter-web @@ -103,6 +126,11 @@ org.springframework.boot spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-actuator + org.springframework.boot spring-boot-starter-mail diff --git a/services/activity-service/.mvn/maven.config b/services/activity-service/.mvn/maven.config new file mode 100644 index 000000000..78dd619a1 --- /dev/null +++ b/services/activity-service/.mvn/maven.config @@ -0,0 +1 @@ +-Dmaven.wagon.http.retryHandler.count=3 diff --git a/services/activity-service/.mvn/wrapper/maven-wrapper.properties b/services/activity-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..308007b56 --- /dev/null +++ b/services/activity-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,2 @@ +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip +wrapperUrl=https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.3.2/maven-wrapper-3.3.2.jar diff --git a/services/activity-service/pom.xml b/services/activity-service/pom.xml new file mode 100644 index 000000000..0b6ff3672 --- /dev/null +++ b/services/activity-service/pom.xml @@ -0,0 +1,176 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + com.danielagapov + spawn-activity-service + 0.0.1-SNAPSHOT + spawn-activity-service + Spawn App Activity Microservice + + + 17 + 1.18.42 + 2023.0.4 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + + com.danielagapov + spawn-common + 0.0.1-SNAPSHOT + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + + + + com.mysql + mysql-connector-j + runtime + + + org.postgresql + postgresql + runtime + + + com.h2database + h2 + runtime + + + + + org.projectlombok + lombok + provided + + + + + io.github.cdimascio + dotenv-java + 3.0.2 + + + + + org.apache.commons + commons-pool2 + + + + + com.fasterxml.jackson.datatype + jackson-datatype-hibernate6 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + + + + central + https://repo.maven.apache.org/maven2 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/ActivityServiceApplication.java b/services/activity-service/src/main/java/com/danielagapov/spawn/ActivityServiceApplication.java new file mode 100644 index 000000000..2e18145a0 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/ActivityServiceApplication.java @@ -0,0 +1,55 @@ +package com.danielagapov.spawn; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableJpaRepositories +@EnableFeignClients +@EnableAsync +@EnableCaching +@EnableScheduling +public class ActivityServiceApplication { + public static void main(String[] args) { + + String activeProfile = System.getProperty("spring.profiles.active", + System.getenv("SPRING_PROFILES_ACTIVE")); + boolean isTestProfile = "test".equals(activeProfile); + + if (!isTestProfile) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + + // Shared MySQL database (same as monolith) + loadEnvVar(dotenv, "MYSQL_URL"); + loadEnvVar(dotenv, "MYSQL_USER"); + loadEnvVar(dotenv, "MYSQL_PASSWORD"); + + // Redis + loadEnvVar(dotenv, "REDIS_HOST"); + loadEnvVar(dotenv, "REDIS_PORT"); + loadEnvVar(dotenv, "REDIS_PASSWORD"); + + // Monolith URL for Feign clients + loadEnvVar(dotenv, "MONOLITH_URL"); + } + + SpringApplication.run(ActivityServiceApplication.class, args); + } + + private static void loadEnvVar(Dotenv dotenv, String key) { + try { + String value = System.getenv(key) != null ? System.getenv(key) : dotenv.get(key); + if (value != null) { + System.setProperty(key, value); + } + } catch (NullPointerException e) { + System.err.println("Warning: " + key + " environment variable not set."); + } + } +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java similarity index 89% rename from src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java index 52f9b632e..6bf31ac48 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityController.java @@ -38,11 +38,9 @@ public ResponseEntity getActivitiesCreatedByUserId(@PathVariable UUID creator try { return new ResponseEntity<>(activityService.convertActivitiesToFullFeedSelfOwnedActivities(activityService.getActivitiesByOwnerId(creatorUserId), creatorUserId), HttpStatus.OK); } catch (BaseNotFoundException e) { - // user or activity not found logger.error("User or activity not found for user: " + LoggingUtils.formatUserIdInfo(creatorUserId) + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } catch (Exception e) { - // any other exception logger.error("Error getting activities created by user: " + LoggingUtils.formatUserIdInfo(creatorUserId) + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } @@ -59,11 +57,9 @@ public ResponseEntity getProfileActivities(@PathVariable UUID profileUserId, try { return new ResponseEntity<>(activityService.getProfileActivities(profileUserId, requestingUserId), HttpStatus.OK); } catch (BaseNotFoundException e) { - // User not found - return 404 logger.error("User not found for profile activities: " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } catch (Exception e) { - // Any other exception logger.error("Error getting profile activities for user: " + LoggingUtils.formatUserIdInfo(profileUserId) + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } @@ -74,7 +70,6 @@ public ResponseEntity getProfileActivities(@PathVariable UUID profileUserId, public ResponseEntity createActivity(@RequestBody ActivityDTO activityDTO) { try { FullFeedActivityDTO createdActivity = activityService.createActivityWithSuggestions(activityDTO); - // Wrap in ActivityCreationResponseDTO to match iOS expected structure ActivityCreationResponseDTO response = new ActivityCreationResponseDTO(createdActivity); return new ResponseEntity<>(response, HttpStatus.CREATED); } catch (IllegalArgumentException e) { @@ -99,12 +94,10 @@ public ResponseEntity replaceActivity(@RequestBody ActivityDTO newActivity, @ try { return new ResponseEntity<>(activityService.replaceActivity(newActivity, id), HttpStatus.OK); } catch (BaseNotFoundException e) { - // Only return 404 if user doesn't exist, not if activity doesn't exist if (e.entityType == EntityType.User) { logger.error("User not found for activity replacement: " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } else if (e.entityType == EntityType.Activity) { - // Return 404 for activities too, as this is specifically looking up an activity by ID logger.error("Activity not found for replacement: " + id + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } else { @@ -157,13 +150,12 @@ public ResponseEntity deleteActivity(@PathVariable UUID id) { try { boolean isDeleted = activityService.deleteActivityById(id); if (isDeleted) { - return new ResponseEntity<>(HttpStatus.NO_CONTENT); // Success + return new ResponseEntity<>(HttpStatus.NO_CONTENT); } else { logger.error("Failed to delete activity: " + id); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); // Deletion failed + return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } catch (BaseNotFoundException e) { - // For deletion, it makes sense to return 404 if the activity doesn't exist logger.error("Activity not found for deletion: " + id + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } catch (Exception e) { @@ -172,7 +164,6 @@ public ResponseEntity deleteActivity(@PathVariable UUID id) { } } - // this corresponds to the button on the activity for invited users // full path: /api/v1/activities/{activityId}/toggle-status/{userId} @PutMapping("{activityId}/toggle-status/{userId}") public ResponseEntity toggleParticipation(@PathVariable UUID activityId, @PathVariable UUID userId) { @@ -184,7 +175,6 @@ public ResponseEntity toggleParticipation(@PathVariable UUID activityId, @Pat FullFeedActivityDTO updatedActivityAfterParticipationToggle = activityService.toggleParticipation(activityId, userId); return new ResponseEntity<>(updatedActivityAfterParticipationToggle, HttpStatus.OK); } catch (BaseNotFoundException e) { - // Only return 404 for appropriate entity types if (e.entityType == EntityType.User) { logger.error("User not found for participation toggle: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); @@ -192,7 +182,6 @@ public ResponseEntity toggleParticipation(@PathVariable UUID activityId, @Pat logger.error("Activity not found for participation toggle: " + activityId + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } else if (e.entityType == EntityType.ActivityUser) { - // If the user is not invited to the activity, return 404 logger.error("User not invited to activity for participation toggle: " + LoggingUtils.formatUserIdInfo(userId) + " in activity: " + activityId + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } else { @@ -209,11 +198,7 @@ public ResponseEntity toggleParticipation(@PathVariable UUID activityId, @Pat } // full path: /api/v1/activities/feed-activities/{requestingUserId} - // this method will return the activities created by a given user (like in `getActivitiesCreatedByUserId()`), - // in the universal accent color, followed by feed activities (like in `getActivitiesInvitedTo()` @GetMapping("feed-activities/{requestingUserId}") - // need this `? extends AbstractActivityDTO` instead of simply `AbstractActivityDTO`, because of this error: - // https://stackoverflow.com/questions/27522741/incompatible-types-inference-variable-t-has-incompatible-bounds public ResponseEntity getFeedActivities(@PathVariable UUID requestingUserId) { if (requestingUserId == null) { logger.error("Invalid parameter: requestingUserId is null"); @@ -222,9 +207,6 @@ public ResponseEntity getFeedActivities(@PathVariable UUID requestingUserId) try { return new ResponseEntity<>(activityService.getFeedActivities(requestingUserId), HttpStatus.OK); } catch (BasesNotFoundException e) { - // thrown list of activities not found for given user id - // if entities not found is Activity: return response with empty list and 200 status - // otherwise: bad request http status if (e.entityType == EntityType.Activity) { return new ResponseEntity<>(new ArrayList<>(), HttpStatus.OK); } else { @@ -232,7 +214,6 @@ public ResponseEntity getFeedActivities(@PathVariable UUID requestingUserId) return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } } catch (BaseNotFoundException e) { - // user not found - return 404 logger.error("User not found for feed activities: " + LoggingUtils.formatUserIdInfo(requestingUserId) + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } catch (Exception e) { @@ -252,12 +233,10 @@ public ResponseEntity getFullActivityById(@PathVariable UUID id, return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } - // For external invites, we don't require requestingUserId and return simplified DTO if (isActivityExternalInvite) { try { return new ResponseEntity<>(activityService.getActivityInviteById(id), HttpStatus.OK); } catch (BaseNotFoundException e) { - // Activity not found if (e.entityType == EntityType.Activity) { logger.error("Activity not found for external invite: " + id + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.NOT_FOUND); @@ -271,14 +250,12 @@ public ResponseEntity getFullActivityById(@PathVariable UUID id, } } - // Original behavior for authenticated requests if (requestingUserId == null) { logger.error("Invalid parameter: requestingUserId is required for authenticated requests"); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } try { - // If autoJoin is true, automatically join the user to the activity if (autoJoin) { logger.info("Auto-joining user " + LoggingUtils.formatUserIdInfo(requestingUserId) + " to activity " + id); return new ResponseEntity<>(activityService.autoJoinUserToActivity(id, requestingUserId), HttpStatus.OK); @@ -286,19 +263,16 @@ public ResponseEntity getFullActivityById(@PathVariable UUID id, return new ResponseEntity<>(activityService.getFullActivityById(id, requestingUserId), HttpStatus.OK); } } catch (BaseNotFoundException e) { - // Activity or User not found - only return 404 if it's the user that's not found if (e.entityType == EntityType.User) { logger.error("User not found for full activity: " + LoggingUtils.formatUserIdInfo(requestingUserId) + ": " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } else if (e.entityType == EntityType.Activity) { - // Activity not found for a valid user, return empty response with 200 return new ResponseEntity<>(new ArrayList<>(), HttpStatus.OK); } else { logger.error("Entity not found for full activity: " + e.getMessage()); return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); } } catch (Exception e) { - // Any other exception logger.error("Error getting full activity by ID: " + id + " for user: " + LoggingUtils.formatUserIdInfo(requestingUserId) + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } diff --git a/src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java index 09035ae78..e561d9439 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/ActivityTypeController.java @@ -60,4 +60,4 @@ public ResponseEntity> updateActivityTypes( return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/IActivityService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/IActivityService.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/IActivityService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/IActivityService.java diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/InternalActivityController.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/InternalActivityController.java new file mode 100644 index 000000000..54a0349ab --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/InternalActivityController.java @@ -0,0 +1,125 @@ +package com.danielagapov.spawn.activity.api; + +import com.danielagapov.spawn.activity.api.dto.*; +import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; +import com.danielagapov.spawn.activity.internal.services.ICalendarService; +import com.danielagapov.spawn.shared.util.ParticipationStatus; +import org.springframework.data.domain.Limit; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("api/v1/activities/internal") +public class InternalActivityController { + + private final IActivityService activityService; + private final ICalendarService calendarService; + private final IActivityTypeService activityTypeService; + + public InternalActivityController(IActivityService activityService, ICalendarService calendarService, + IActivityTypeService activityTypeService) { + this.activityService = activityService; + this.calendarService = calendarService; + this.activityTypeService = activityTypeService; + } + + @GetMapping("created-by/{userId}") + public ResponseEntity> getActivityIdsCreatedByUser(@PathVariable UUID userId) { + return ResponseEntity.ok(activityService.getActivityIdsCreatedByUser(userId)); + } + + @GetMapping("by-user") + public ResponseEntity> getActivityIdsByUserAndStatus( + @RequestParam UUID userId, + @RequestParam ParticipationStatus status) { + return ResponseEntity.ok(activityService.getActivityIdsByUserIdAndStatus(userId, status)); + } + + @GetMapping("{activityId}/participant-ids") + public ResponseEntity> getParticipantUserIds( + @PathVariable UUID activityId, + @RequestParam ParticipationStatus status) { + return ResponseEntity.ok(activityService.getParticipantUserIdsByActivityIdAndStatus(activityId, status)); + } + + @GetMapping("{activityId}/creator-id") + public ResponseEntity getCreatorId(@PathVariable UUID activityId) { + return ResponseEntity.ok(activityService.getActivityCreatorId(activityId)); + } + + @GetMapping("{activityId}/title") + public ResponseEntity getActivityTitle(@PathVariable UUID activityId) { + return ResponseEntity.ok(activityService.getActivityTitle(activityId)); + } + + @GetMapping("past") + public ResponseEntity> getPastActivityIdsForUser( + @RequestParam UUID userId, + @RequestParam ParticipationStatus status, + @RequestParam(defaultValue = "10") int limit) { + OffsetDateTime now = OffsetDateTime.now(ZoneOffset.UTC); + return ResponseEntity.ok(activityService.getPastActivityIdsForUser(userId, status, now, Limit.of(limit))); + } + + @PostMapping("other-users") + public ResponseEntity> getOtherUsersByActivities( + @RequestBody List activityIds, + @RequestParam UUID excludeUserId, + @RequestParam ParticipationStatus status) { + return ResponseEntity.ok(activityService.getOtherUserIdsByActivityIds(activityIds, excludeUserId, status)); + } + + @GetMapping("shared-count") + public ResponseEntity getSharedActivitiesCount( + @RequestParam UUID userId1, + @RequestParam UUID userId2, + @RequestParam ParticipationStatus status) { + return ResponseEntity.ok(activityService.getSharedActivitiesCount(userId1, userId2, status)); + } + + @GetMapping("calendar") + public ResponseEntity> getCalendarActivities( + @RequestParam UUID userId, + @RequestParam(required = false) Integer month, + @RequestParam(required = false) Integer year) { + return ResponseEntity.ok(calendarService.getCalendarActivitiesWithFilters(userId, month, year)); + } + + @GetMapping("timestamps/created") + public ResponseEntity getLatestCreatedTimestamp(@RequestParam UUID userId) { + return ResponseEntity.ok(activityService.getLatestCreatedActivityTimestamp(userId)); + } + + @GetMapping("timestamps/invited") + public ResponseEntity getLatestInvitedTimestamp(@RequestParam UUID userId) { + return ResponseEntity.ok(activityService.getLatestInvitedActivityTimestamp(userId)); + } + + @GetMapping("timestamps/updated") + public ResponseEntity getLatestUpdatedTimestamp(@RequestParam UUID userId) { + return ResponseEntity.ok(activityService.getLatestUpdatedActivityTimestamp(userId)); + } + + @GetMapping("activity-types/{userId}") + public ResponseEntity> getActivityTypesByUserId(@PathVariable UUID userId) { + return ResponseEntity.ok(activityTypeService.getActivityTypesByUserId(userId)); + } + + @PostMapping("calendar/clear-all") + public ResponseEntity clearAllCalendarCaches() { + calendarService.clearAllCalendarCaches(); + return ResponseEntity.ok().build(); + } + + @PostMapping("activity-types/initialize-for-user/{userId}") + public ResponseEntity initializeActivityTypesForUser(@PathVariable UUID userId) { + activityTypeService.initializeDefaultActivityTypesForUserId(userId); + return ResponseEntity.ok().build(); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java new file mode 100644 index 000000000..7cf8ba524 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java @@ -0,0 +1,37 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.UUID; +import com.fasterxml.jackson.annotation.JsonProperty; + +@NoArgsConstructor +@Getter +@Setter +public abstract class AbstractActivityDTO implements Serializable { + UUID id; + String title; + OffsetDateTime startTime; + OffsetDateTime endTime; + String note; + String icon; + Integer participantLimit; + Instant createdAt; + + @JsonProperty("isExpired") + boolean isExpired; + + String clientTimezone; + + public AbstractActivityDTO(UUID id, String title, OffsetDateTime startTime, OffsetDateTime endTime, + String note, String icon, Integer participantLimit, Instant createdAt, boolean isExpired, String clientTimezone) { + this.id = id; this.title = title; this.startTime = startTime; this.endTime = endTime; + this.note = note; this.icon = icon; this.participantLimit = participantLimit; + this.createdAt = createdAt; this.isExpired = isExpired; this.clientTimezone = clientTimezone; + } +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java index 580a0dff8..188998df1 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java @@ -46,4 +46,4 @@ public ActivityDTO(UUID id, this.chatMessageIds = chatMessageIds; this.clientTimezone = clientTimezone; } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java index 1f4a14dba..e09cf4e9e 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java @@ -45,4 +45,4 @@ public ActivityInviteDTO(UUID id, this.participantUserIds = participantUserIds; this.invitedUserIds = invitedUserIds; } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java index c64486aa8..bf5c9e529 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java @@ -15,4 +15,4 @@ public class ActivityParticipationDTO implements Serializable { UUID activityId; UUID userId; ParticipationStatus status; -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java index 0c957cefa..06ab555a6 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java @@ -17,4 +17,4 @@ public class CalendarActivityDTO implements Serializable { private String icon; // Icon for the calendar Activity (emoji) private String colorHexCode; // Color for the calendar Activity private UUID activityId; // Optional, if the activity is linked to a spawn Activity -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java index 9f9dca7bd..b2e8b46e7 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java @@ -58,4 +58,4 @@ public FullFeedActivityDTO(UUID id, this.participationStatus = participationStatus; this.isSelfOwned = isSelfOwned; } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java index 3bc8c485e..cf04ddc31 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java @@ -17,4 +17,4 @@ public class LocationDTO implements Serializable { String name; double latitude; double longitude; -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java index c5721d59e..9f2ac0f61 100644 --- a/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java @@ -89,4 +89,4 @@ public static ProfileActivityDTO fromFullFeedActivityDTO(FullFeedActivityDTO ful isPastActivity ); } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java similarity index 67% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java index e28e0dc64..e25417063 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Activity.java @@ -15,13 +15,6 @@ import java.time.OffsetDateTime; import java.util.UUID; -/** - * An activity is the primary function of our app. - * Upon creation, the creating user can invite many - * friends directly, or by friend tags, that they've placed - * friends into. Then, those invited users can choose to - * participate in that activity. - */ @Entity @Table( name = "activity", @@ -39,9 +32,7 @@ @Setter @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class Activity implements Serializable { - private @Id - @GeneratedValue UUID id; - + private @Id @GeneratedValue UUID id; private String title; private OffsetDateTime startTime; private OffsetDateTime endTime; @@ -62,7 +53,7 @@ public class Activity implements Serializable { private String note; @Column(name = "participant_limit") - private Integer participantLimit; // null means unlimited participants + private Integer participantLimit; @ManyToOne @JoinColumn(name = "creator_id", referencedColumnName = "id", nullable = false) @@ -77,16 +68,12 @@ public class Activity implements Serializable { private Instant lastUpdated; @Column(name = "client_timezone") - private String clientTimezone; // Timezone of the client creating the activity (e.g., "America/New_York") + private String clientTimezone; @PrePersist public void prePersist() { - if (this.createdAt == null) { - this.createdAt = Instant.now(); - } - if (this.lastUpdated == null) { - this.lastUpdated = Instant.now(); - } + if (this.createdAt == null) this.createdAt = Instant.now(); + if (this.lastUpdated == null) this.lastUpdated = Instant.now(); } @PreUpdate @@ -95,29 +82,14 @@ public void preUpdate() { } public Activity(UUID id, String title, OffsetDateTime startTime, OffsetDateTime endTime, Location location, String note, User creator, String icon) { - this.id = id; - this.title = title; - this.startTime = startTime; - this.endTime = endTime; - this.location = location; - this.note = note; - this.creator = creator; - this.createdAt = Instant.now(); - this.lastUpdated = Instant.now(); - this.icon = icon; + this.id = id; this.title = title; this.startTime = startTime; this.endTime = endTime; + this.location = location; this.note = note; this.creator = creator; + this.createdAt = Instant.now(); this.lastUpdated = Instant.now(); this.icon = icon; } public Activity(UUID id, String title, OffsetDateTime startTime, OffsetDateTime endTime, Location location, String note, User creator, String icon, String clientTimezone) { - this.id = id; - this.title = title; - this.startTime = startTime; - this.endTime = endTime; - this.location = location; - this.note = note; - this.creator = creator; - this.createdAt = Instant.now(); - this.lastUpdated = Instant.now(); - this.icon = icon; - this.clientTimezone = clientTimezone; + this.id = id; this.title = title; this.startTime = startTime; this.endTime = endTime; + this.location = location; this.note = note; this.creator = creator; + this.createdAt = Instant.now(); this.lastUpdated = Instant.now(); this.icon = icon; this.clientTimezone = clientTimezone; } } diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java similarity index 70% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java index 6dc4aef41..10b65503d 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityType.java @@ -22,14 +22,13 @@ @Setter @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class ActivityType { - @Id - @GeneratedValue + @Id @GeneratedValue private UUID id; private String title; @ManyToMany(fetch = FetchType.LAZY) @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) - private List associatedFriends = new ArrayList<>(); // Initialize with empty list + private List associatedFriends = new ArrayList<>(); @ManyToOne(optional = false, fetch = FetchType.LAZY) @OnDelete(action = OnDeleteAction.CASCADE) @@ -37,28 +36,21 @@ public class ActivityType { @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) private User creator; private Integer orderNum; - @Column(length = 100, columnDefinition = "VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") // For Emojis - private String icon = "⭐"; // Default value + @Column(length = 100, columnDefinition = "VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + private String icon = "⭐"; @Column(nullable = false) - private Boolean isPinned = false; // Default value for pinned status + private Boolean isPinned = false; - // Constructor for basic creation public ActivityType(User creator, String title, String icon) { - this.creator = creator; - this.title = title; - this.icon = icon; - this.associatedFriends = new ArrayList<>(); // Initialize with empty list - this.isPinned = false; // Default to unpinned + this.creator = creator; this.title = title; this.icon = icon; + this.associatedFriends = new ArrayList<>(); this.isPinned = false; } - // Comprehensive constructor for mapper usage public ActivityType(UUID id, String title, List associatedFriends, User creator, Integer orderNum, String icon, Boolean isPinned) { - this.id = id; - this.title = title; + this.id = id; this.title = title; this.associatedFriends = associatedFriends != null ? associatedFriends : new ArrayList<>(); - this.creator = creator; - this.orderNum = orderNum; + this.creator = creator; this.orderNum = orderNum; this.icon = icon != null ? icon : "⭐"; this.isPinned = isPinned != null ? isPinned : false; } diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java similarity index 80% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java index 21fe3e074..d98cd5456 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUser.java @@ -1,7 +1,6 @@ package com.danielagapov.spawn.activity.internal.domain; import com.danielagapov.spawn.shared.util.ParticipationStatus; -import com.danielagapov.spawn.activity.internal.domain.ActivityUsersId; import com.danielagapov.spawn.user.internal.domain.User; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import jakarta.persistence.*; @@ -14,13 +13,6 @@ import java.io.Serializable; -/** - * An `ActivityUser` represents either a participant or - * invited user to an activity. Upon creation, the activity's - * creator can invite another user to an activity, during which - * they're added into this table with a status = ParticipationStatus.invited. - * Once they've chosen to participate, their status is flipped to .participating. - */ @Entity @Table( name = "activity_user", diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java similarity index 79% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java index 06f56b14a..313790f63 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/ActivityUsersId.java @@ -27,12 +27,9 @@ public class ActivityUsersId implements Serializable { public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ActivityUsersId that)) return false; - return Objects.equals(activityId, that.activityId) && - Objects.equals(userId, that.userId); + return Objects.equals(activityId, that.activityId) && Objects.equals(userId, that.userId); } @Override - public int hashCode() { - return Objects.hash(activityId, userId); - } -} \ No newline at end of file + public int hashCode() { return Objects.hash(activityId, userId); } +} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java similarity index 57% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java index 67917ec3f..1cda8abc4 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/domain/Location.java @@ -10,16 +10,6 @@ import java.io.Serializable; import java.util.UUID; -/** - * This represents a location attached to - * a particular Activity, with a 1-to-1 relationship. - * A location cannot exist without an Activity, - * and this Activity is essentially making an object - * out of this sub-object to an Activity. - * A user will be able to input a location with - * its coordinates + a display name for their friends, - * since we want it to be easily understandable. - */ @Entity @NoArgsConstructor @AllArgsConstructor @@ -27,13 +17,10 @@ @Setter @JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) public class Location implements Serializable { - @Id - @GeneratedValue(strategy = GenerationType.AUTO) + @Id @GeneratedValue(strategy = GenerationType.AUTO) private UUID id; - @Column(length = 200) private String name; - private double latitude; private double longitude; -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityRepository.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityRepository.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityRepository.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityRepository.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityTypeRepository.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityTypeRepository.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityTypeRepository.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityTypeRepository.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityUserRepository.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityUserRepository.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityUserRepository.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IActivityUserRepository.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/ILocationRepository.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/ILocationRepository.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/repositories/ILocationRepository.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/repositories/ILocationRepository.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java similarity index 70% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java index 4d624e679..84d86c84a 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityCacheCleanupService.java @@ -22,20 +22,15 @@ public class ActivityCacheCleanupService { /** * Periodically evicts all activity-related caches to ensure fresh expiration data. - * Runs every 15 minutes to prevent serving stale activities that have expired. - * - * Note: This is a preventive measure in addition to the 5-minute TTL. - * Activities can expire at any time, so we proactively clear caches to ensure - * the isExpired field is recalculated based on current time. + * Runs every 5 minutes to prevent serving stale activities that have expired. */ - @Scheduled(fixedRate = 300000) // 5 minutes = 600,000 milliseconds + @Scheduled(fixedRate = 300000) // 5 minutes public void cleanupExpiredActivityCaches() { try { - logger.info("🧹 Starting scheduled activity cache cleanup"); + logger.info("Starting scheduled activity cache cleanup"); int clearedCaches = 0; - // Clear all activity-related caches String[] activityCaches = { "feedActivities", "fullActivityById", @@ -54,16 +49,15 @@ public void cleanupExpiredActivityCaches() { if (cacheManager.getCache(cacheName) != null) { cacheManager.getCache(cacheName).clear(); clearedCaches++; - logger.debug("✅ Cleared cache: {}", cacheName); + logger.debug("Cleared cache: {}", cacheName); } } - logger.info("✅ Completed activity cache cleanup - cleared {} caches", clearedCaches); + logger.info("Completed activity cache cleanup - cleared {} caches", clearedCaches); } catch (Exception e) { - logger.error("❌ Error during activity cache cleanup: {}", e.getMessage(), e); + logger.error("Error during activity cache cleanup: {}", e.getMessage(), e); // Don't throw - this is a best-effort background task } } } - diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java similarity index 73% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java index 53725ea83..8ec6a2130 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityExpirationService.java @@ -80,14 +80,11 @@ public boolean isActivityExpired(OffsetDateTime startTime, OffsetDateTime endTim return now.isAfter(endOfCreationDay); } catch (Exception e) { // If timezone parsing fails, fall back to UTC behavior - // Log the error but continue with UTC fallback to ensure system stability } } // Fallback to UTC behavior (original logic) - // Get the date the activity was created (in UTC) LocalDate createdDate = createdAt.atOffset(ZoneOffset.UTC).toLocalDate(); - // Calculate end of that day (23:59:59.999 UTC) OffsetDateTime endOfCreationDay = createdDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toOffsetDateTime(); return now.isAfter(endOfCreationDay); } @@ -99,11 +96,6 @@ public boolean isActivityExpired(OffsetDateTime startTime, OffsetDateTime endTim /** * Calculates when an activity will expire. * Used for share link expiration and other time-based operations. - * - * @param startTime The activity start time (can be null) - * @param endTime The activity end time (can be null) - * @param createdAt The activity creation time (can be null) - * @return The expiration time in UTC, or null if the activity never expires */ public OffsetDateTime calculateActivityExpiration(OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt) { return calculateActivityExpiration(startTime, endTime, createdAt, null); @@ -111,60 +103,36 @@ public OffsetDateTime calculateActivityExpiration(OffsetDateTime startTime, Offs /** * Calculates when an activity will expire, considering client timezone. - * Used for share link expiration and other time-based operations. - * - * @param startTime The activity start time (can be null) - * @param endTime The activity end time (can be null) - * @param createdAt The activity creation time (can be null) - * @param clientTimezone The timezone of the client that created the activity (can be null) - * @return The expiration time in UTC, or null if the activity never expires */ public OffsetDateTime calculateActivityExpiration(OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt, String clientTimezone) { - // Activities with explicit end time expire at their end time if (endTime != null) { return endTime.withOffsetSameInstant(ZoneOffset.UTC); } - // Activities without end time expire at the end of the day they were created if (createdAt != null) { if (clientTimezone != null && !clientTimezone.trim().isEmpty()) { try { - // Use client timezone for expiration calculation ZoneId clientZone = ZoneId.of(clientTimezone); - - // Get the date the activity was created in the client's timezone LocalDate createdDate = createdAt.atZone(clientZone).toLocalDate(); - - // Return midnight (12 AM) of the following day in client timezone, converted to UTC return createdDate.plusDays(1) .atStartOfDay(clientZone) .toOffsetDateTime() .withOffsetSameInstant(ZoneOffset.UTC); } catch (Exception e) { - // If timezone parsing fails, fall back to UTC behavior - // Log the error but continue with UTC fallback to ensure system stability + // Fall back to UTC } } - // Fallback to UTC behavior (original logic) - // Get the date the activity was created (in UTC) LocalDate createdDate = createdAt.atOffset(ZoneOffset.UTC).toLocalDate(); - // Return end of that day (start of next day) return createdDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toOffsetDateTime(); } - // Activities with no creation time never expire return null; } /** * Calculates when a share link for an activity should expire. * Share links expire 1 day after the activity itself expires. - * - * @param startTime The activity start time (can be null) - * @param endTime The activity end time (can be null) - * @param createdAt The activity creation time (can be null) - * @return The share link expiration time, or null if it should never expire */ public OffsetDateTime calculateShareLinkExpiration(OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt) { return calculateShareLinkExpiration(startTime, endTime, createdAt, null); @@ -173,22 +141,14 @@ public OffsetDateTime calculateShareLinkExpiration(OffsetDateTime startTime, Off /** * Calculates when a share link for an activity should expire, considering client timezone. * Share links expire 1 day after the activity itself expires. - * - * @param startTime The activity start time (can be null) - * @param endTime The activity end time (can be null) - * @param createdAt The activity creation time (can be null) - * @param clientTimezone The timezone of the client that created the activity (can be null) - * @return The share link expiration time, or null if it should never expire */ public OffsetDateTime calculateShareLinkExpiration(OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt, String clientTimezone) { OffsetDateTime activityExpiration = calculateActivityExpiration(startTime, endTime, createdAt, clientTimezone); if (activityExpiration != null) { - // Share link expires 1 day after activity expires return activityExpiration.plusDays(1); } - // If activity never expires, share link also never expires return null; } } diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityService.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityService.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java index 9389717d0..2f4c108f7 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeService.java @@ -196,6 +196,13 @@ private void updateExistingActivityTypesWithConstraintHandling(List getChatMessageIdsByActivityId(UUID activityId) { + try { + List messages = monolithChatClient.getChatMessagesByActivityId(activityId); + return messages.stream() + .map(FullActivityChatMessageDTO::getId) + .collect(Collectors.toList()); + } catch (Exception e) { + logger.warn("Could not fetch chat message IDs for activity " + activityId + ": " + e.getMessage()); + return Collections.emptyList(); + } + } + + /** + * Batch get chat message IDs for multiple activities. + * Makes individual calls per activity (could be optimized with a batch endpoint later). + */ + @Override + public Map> getChatMessageIdsByActivityIds(List activityIds) { + if (activityIds == null || activityIds.isEmpty()) { + return Collections.emptyMap(); + } + + Map> result = new HashMap<>(); + for (UUID activityId : activityIds) { + List messageIds = getChatMessageIdsByActivityId(activityId); + if (!messageIds.isEmpty()) { + result.put(activityId, messageIds); + } + } + return result; + } + + /** + * Get full chat messages for an activity via Feign call to monolith. + */ + @Override + public List getFullChatMessagesByActivityId(UUID activityId) { + try { + return monolithChatClient.getChatMessagesByActivityId(activityId); + } catch (Exception e) { + logger.warn("Could not fetch full chat messages for activity " + activityId + ": " + e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java similarity index 87% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java index 3c1f0adb8..3a0acac32 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IActivityTypeService.java @@ -31,6 +31,13 @@ public interface IActivityTypeService { * @param user The user to initialize activity types for */ void initializeDefaultActivityTypesForUser(User user); + + /** + * Initialize default activity types for a user by ID (for internal/Feign calls). + * Fetches user from DB and delegates to initializeDefaultActivityTypesForUser. + * @param userId The user ID to initialize activity types for + */ + void initializeDefaultActivityTypesForUserId(UUID userId); /** * Set the order number for an activity type diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ICalendarService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ICalendarService.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ICalendarService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ICalendarService.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java similarity index 82% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java index 75e5ef26a..68e6a2098 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/IChatQueryService.java @@ -7,8 +7,9 @@ import java.util.UUID; /** - * Interface for querying chat data from the Chat module via events. - * This breaks the circular dependency between Activity and Chat modules. + * Interface for querying chat data from the Chat module. + * In the microservice, this uses a Feign client to call the monolith + * (where the Chat module still lives) instead of in-process events. */ public interface IChatQueryService { @@ -30,7 +31,7 @@ public interface IChatQueryService { /** * Get full chat messages for an activity via event query. - * Converts ChatMessageData to FullActivityChatMessageDTO with user lookups. + * Returns full chat message DTOs from chat-service. * * @param activityId The activity ID * @return List of full chat message DTOs, or empty list if none found or on error @@ -39,4 +40,3 @@ public interface IChatQueryService { } - diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ILocationService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ILocationService.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ILocationService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/ILocationService.java diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java similarity index 99% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java index 473a5ca97..305c28835 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/activity/internal/services/LocationService.java @@ -62,4 +62,4 @@ public Location save(Location location) { throw new ApplicationException("Failed to save location", e); } } -} \ No newline at end of file +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java new file mode 100644 index 000000000..a5c485d82 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfiguration { +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java new file mode 100644 index 000000000..561ea1ddb --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java @@ -0,0 +1,47 @@ +package com.danielagapov.spawn.shared.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; + +import java.time.Duration; + +@Configuration +@EnableCaching +public class RedisConfig { + + @Bean + public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + mapper.activateDefaultTyping(mapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL); + + GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer(mapper); + + RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofHours(1)) + .serializeValuesWith( + RedisSerializationContext.SerializationPair.fromSerializer(serializer) + ); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(config) + .build(); + } + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java new file mode 100644 index 000000000..022c84a40 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.shared.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/api/**").permitAll() + .anyRequest().permitAll() + ); + return http.build(); + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/ActivityInviteNotificationEvent.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityInviteNotificationEvent.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/events/ActivityInviteNotificationEvent.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityInviteNotificationEvent.java diff --git a/src/main/java/com/danielagapov/spawn/shared/events/ActivityParticipationNotificationEvent.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityParticipationNotificationEvent.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/events/ActivityParticipationNotificationEvent.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityParticipationNotificationEvent.java diff --git a/src/main/java/com/danielagapov/spawn/shared/events/ActivityUpdateNotificationEvent.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityUpdateNotificationEvent.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/events/ActivityUpdateNotificationEvent.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/ActivityUpdateNotificationEvent.java diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/NotificationEvent.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/NotificationEvent.java new file mode 100644 index 000000000..26f517a18 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/NotificationEvent.java @@ -0,0 +1,107 @@ +package com.danielagapov.spawn.shared.events; + +import com.danielagapov.spawn.shared.util.NotificationType; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +/** + * Base class for notification Activities in the application + */ +public abstract class NotificationEvent { + private final NotificationType type; + private final Map data; + private final List targetUserIds = new ArrayList<>(); + private String title; + private String message; + + protected NotificationEvent(NotificationType type) { + this.type = type; + this.data = new HashMap<>(); + this.data.put("type", type.name().toLowerCase()); + } + + /** + * Add a target user to receive this notification + */ + protected void addTargetUser(UUID userId) { + if (userId != null && !targetUserIds.contains(userId)) { + targetUserIds.add(userId); + } + } + + /** + * Add multiple target users to receive this notification + */ + protected void addTargetUsers(List userIds) { + if (userIds != null) { + userIds.forEach(this::addTargetUser); + } + } + + /** + * Add data to the notification + */ + protected void addData(String key, String value) { + data.put(key, value); + } + + /** + * Get the type of the notification + */ + public NotificationType getType() { + return type; + } + + /** + * Get the target user IDs + */ + public List getTargetUserIds() { + return targetUserIds; + } + + /** + * Get the notification data + */ + public Map getData() { + return data; + } + + /** + * Get the notification title + */ + public String getTitle() { + return title; + } + + /** + * Set the notification title + */ + protected void setTitle(String title) { + this.title = title; + } + + /** + * Get the notification message + */ + public String getMessage() { + return message; + } + + /** + * Set the notification message + */ + protected void setMessage(String message) { + this.message = message; + } + + /** + * Find the target users for this notification. + * This method should be implemented by subclasses to determine + * which users should receive the notification. + */ + public abstract void findTargetUsers(); +} \ No newline at end of file diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/UserActivityTypeEvents.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/UserActivityTypeEvents.java new file mode 100644 index 000000000..d7d11243b --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/UserActivityTypeEvents.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.shared.events; + +import java.util.UUID; + +/** + * Domain events for User-ActivityType module inter-module communication. + * Used to break circular dependencies between User and Activity modules. + */ +public final class UserActivityTypeEvents { + + private UserActivityTypeEvents() { + // Utility class - prevent instantiation + } + + /** + * Event published when a new user is created and needs default activity types initialized. + * Published by User module, consumed by Activity module. + */ + public record UserCreatedEvent( + UUID userId, + String username + ) {} + + /** + * Event published when default activity types have been initialized for a user. + * Published by Activity module in response to UserCreatedEvent. + */ + public record DefaultActivityTypesInitializedEvent( + UUID userId, + int activityTypeCount + ) {} +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/ActivityNotificationPublisher.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/ActivityNotificationPublisher.java new file mode 100644 index 000000000..959e7d152 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/ActivityNotificationPublisher.java @@ -0,0 +1,60 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.danielagapov.spawn.shared.events.ActivityInviteNotificationEvent; +import com.danielagapov.spawn.shared.events.ActivityParticipationNotificationEvent; +import com.danielagapov.spawn.shared.events.ActivityUpdateNotificationEvent; +import com.danielagapov.spawn.shared.events.NotificationEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +/** + * Bridges in-process notification events to Redis Pub/Sub. + *

+ * The ActivityService publishes notification events via Spring's ApplicationEventPublisher. + * This listener intercepts them and re-publishes to Redis so the monolith's + * notification module (which subscribes to these channels) can send push notifications. + */ +@Component +public class ActivityNotificationPublisher { + + private static final Logger log = LoggerFactory.getLogger(ActivityNotificationPublisher.class); + private final RedisEventPublisher redisEventPublisher; + + public ActivityNotificationPublisher(RedisEventPublisher redisEventPublisher) { + this.redisEventPublisher = redisEventPublisher; + } + + @EventListener + public void handleActivityInvite(ActivityInviteNotificationEvent event) { + publishToRedis(RedisEventChannels.ACTIVITY_INVITE, event); + } + + @EventListener + public void handleActivityUpdate(ActivityUpdateNotificationEvent event) { + publishToRedis(RedisEventChannels.ACTIVITY_UPDATED, event); + } + + @EventListener + public void handleActivityParticipation(ActivityParticipationNotificationEvent event) { + publishToRedis(RedisEventChannels.ACTIVITY_PARTICIPATION_CHANGED, event); + } + + private void publishToRedis(String channel, NotificationEvent event) { + try { + // Publish a serializable representation of the notification + var payload = new java.util.HashMap(); + payload.put("type", event.getType().name()); + payload.put("title", event.getTitle()); + payload.put("message", event.getMessage()); + payload.put("targetUserIds", event.getTargetUserIds()); + payload.put("data", event.getData()); + + redisEventPublisher.publish(channel, payload); + } catch (Exception e) { + log.error("Failed to publish notification event to Redis channel '{}': {}", channel, e.getMessage()); + // Non-critical — activity operations should not fail because of notification issues + } + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java new file mode 100644 index 000000000..6397bb681 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.shared.events.redis; + +/** + * Redis Pub/Sub channel names for cross-service events. + *

+ * Centralised here so publishers and subscribers reference the same channel names. + * As more services are extracted, add new channels here. + */ +public final class RedisEventChannels { + + private RedisEventChannels() { + // utility class + } + + /** Published by auth-service when a new user completes registration. */ + public static final String USER_REGISTERED = "events:user-registered"; + + /** Published by auth-service when a user accepts Terms of Service. */ + public static final String USER_TOS_ACCEPTED = "events:user-tos-accepted"; + + /** Published by activity-service when a new activity is created. */ + public static final String ACTIVITY_CREATED = "events:activity-created"; + + /** Published by activity-service when users are invited to an activity. */ + public static final String ACTIVITY_INVITE = "events:activity-invite"; + + /** Published by activity-service when an activity is updated. */ + public static final String ACTIVITY_UPDATED = "events:activity-updated"; + + /** Published by activity-service when a user joins/leaves an activity. */ + public static final String ACTIVITY_PARTICIPATION_CHANGED = "events:activity-participation-changed"; +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java new file mode 100644 index 000000000..87e9f46d1 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java @@ -0,0 +1,47 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Publishes domain events to Redis Pub/Sub channels. + *

+ * Events are serialised to JSON so any subscriber (regardless of language or + * framework) can consume them. Use {@link RedisEventChannels} for channel names. + */ +@Component +public class RedisEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(RedisEventPublisher.class); + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisEventPublisher(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + /** + * Publish an event to the given Redis channel. + * + * @param channel one of {@link RedisEventChannels} constants + * @param event the event object (will be serialised to JSON) + */ + public void publish(String channel, Object event) { + try { + String json = objectMapper.writeValueAsString(event); + redisTemplate.convertAndSend(channel, json); + log.info("Published event to channel '{}': {}", channel, json); + } catch (JsonProcessingException e) { + log.error("Failed to serialise event for channel '{}': {}", channel, e.getMessage()); + } catch (Exception e) { + log.error("Failed to publish event to channel '{}': {}", channel, e.getMessage()); + } + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java new file mode 100644 index 000000000..8c93e250a --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java @@ -0,0 +1,22 @@ +package com.danielagapov.spawn.shared.feign; + +import feign.Logger; +import feign.Request; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +public class FeignConfig { + + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.BASIC; + } + + @Bean + public Request.Options requestOptions() { + return new Request.Options(5, TimeUnit.SECONDS, 10, TimeUnit.SECONDS, true); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClient.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClient.java new file mode 100644 index 000000000..3860c5104 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClient.java @@ -0,0 +1,27 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; +import java.util.UUID; + +/** + * Feign client for fetching chat data from the chat-service. + * The activity-service needs chat messages for building full activity DTOs. + */ +@FeignClient( + name = "chat-service-client", + url = "${services.chat-service.url:http://localhost:8083}", + fallbackFactory = MonolithChatClientFallbackFactory.class +) +public interface MonolithChatClient { + + /** + * Get full chat messages for a specific activity. + */ + @GetMapping("/api/v1/chat-messages/by-activity/{activityId}") + List getChatMessagesByActivityId(@PathVariable("activityId") UUID activityId); +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClientFallbackFactory.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClientFallbackFactory.java new file mode 100644 index 000000000..5f6640672 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithChatClientFallbackFactory.java @@ -0,0 +1,34 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +/** + * Fallback factory for MonolithChatClient. + * Returns empty lists instead of throwing exceptions — chat messages are + * non-critical and the activity feed should still work without them. + */ +@Component +public class MonolithChatClientFallbackFactory implements FallbackFactory { + + private static final Logger log = LoggerFactory.getLogger(MonolithChatClientFallbackFactory.class); + + @Override + public MonolithChatClient create(Throwable cause) { + return new MonolithChatClient() { + + @Override + public List getChatMessagesByActivityId(UUID activityId) { + log.warn("Fallback: could not fetch chat messages for activity {}. Cause: {}", activityId, cause.getMessage()); + return Collections.emptyList(); + } + }; + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java new file mode 100644 index 000000000..98ebbdfbf --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java @@ -0,0 +1,39 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.api.dto.FriendUser.FullFriendUserDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.List; +import java.util.UUID; + +@FeignClient( + name = "monolith-user-client", + url = "${services.monolith.url}", + fallbackFactory = MonolithUserClientFallbackFactory.class +) +public interface MonolithUserClient { + + @GetMapping("/api/v1/users/{id}") + BaseUserDTO getUserById(@PathVariable("id") UUID id); + + @GetMapping("/api/v1/users/{id}/full") + UserDTO getFullUserById(@PathVariable("id") UUID id); + + @GetMapping("/api/v1/users/{userId}/friends") + List getFriendsByUserId(@PathVariable("userId") UUID userId); + + @GetMapping("/api/v1/users/by-username") + BaseUserDTO getUserByUsername(@RequestParam("username") String username); + + @GetMapping("/api/v1/users/exists/by-username") + boolean existsByUsername(@RequestParam("username") String username); + + @GetMapping("/api/v1/users/exists/by-email") + boolean existsByEmail(@RequestParam("email") String email); +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java new file mode 100644 index 000000000..e1019bf37 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java @@ -0,0 +1,60 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.api.dto.FriendUser.FullFriendUserDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +@Component +public class MonolithUserClientFallbackFactory implements FallbackFactory { + + private static final Logger log = LoggerFactory.getLogger(MonolithUserClientFallbackFactory.class); + + @Override + public MonolithUserClient create(Throwable cause) { + return new MonolithUserClient() { + + @Override + public BaseUserDTO getUserById(UUID id) { + log.error("Fallback: monolith unreachable when fetching user by id {}. Cause: {}", id, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public UserDTO getFullUserById(UUID id) { + log.error("Fallback: monolith unreachable when fetching full user by id {}. Cause: {}", id, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public List getFriendsByUserId(UUID userId) { + log.error("Fallback: monolith unreachable when fetching friends for user {}. Cause: {}", userId, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public BaseUserDTO getUserByUsername(String username) { + log.error("Fallback: monolith unreachable when fetching user by username {}. Cause: {}", username, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public boolean existsByUsername(String username) { + log.error("Fallback: monolith unreachable when checking username exists {}. Cause: {}", username, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public boolean existsByEmail(String email) { + log.error("Fallback: monolith unreachable when checking email exists {}. Cause: {}", email, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + }; + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/util/ActivityMapper.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/ActivityMapper.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/util/ActivityMapper.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/ActivityMapper.java diff --git a/src/main/java/com/danielagapov/spawn/shared/util/ActivityTypeMapper.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/ActivityTypeMapper.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/util/ActivityTypeMapper.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/ActivityTypeMapper.java diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheEvictionHelper.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheEvictionHelper.java new file mode 100644 index 000000000..6bd5b5b50 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheEvictionHelper.java @@ -0,0 +1,226 @@ +package com.danielagapov.spawn.shared.util; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Utility class for safe cache eviction operations. + * + * This helper centralizes all cache eviction logic, providing: + * - Null-safe cache access + * - Consistent error handling and logging + * - Bulk eviction operations + * - Reduces code duplication across services + * + * All methods are best-effort: they log errors but never throw exceptions, + * allowing the application to continue even if cache operations fail. + */ +@Component +public class CacheEvictionHelper { + + private static final Logger logger = LoggerFactory.getLogger(CacheEvictionHelper.class); + + private final CacheManager cacheManager; + + @Autowired + public CacheEvictionHelper(CacheManager cacheManager) { + this.cacheManager = cacheManager; + } + + /** + * Safely evicts a single cache entry. + * + * @param cacheName The name of the cache + * @param key The key to evict + */ + public void evictCache(String cacheName, Object key) { + try { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.evict(key); + logger.debug("Evicted cache '{}' for key: {}", cacheName, key); + } else { + logger.warn("Cache '{}' not found when attempting to evict key: {}", cacheName, key); + } + } catch (Exception e) { + logger.error("Error evicting cache '{}' for key {}: {}", cacheName, key, e.getMessage()); + // Don't throw - this is a best-effort operation + } + } + + /** + * Safely evicts the same key from multiple caches. + * + * @param key The key to evict + * @param cacheNames The names of the caches to evict from + */ + public void evictCaches(Object key, String... cacheNames) { + for (String cacheName : cacheNames) { + evictCache(cacheName, key); + } + } + + /** + * Safely evicts multiple keys from the same cache. + * + * @param cacheName The name of the cache + * @param keys The keys to evict + */ + public void evictCacheForKeys(String cacheName, Object... keys) { + for (Object key : keys) { + evictCache(cacheName, key); + } + } + + /** + * Safely evicts a cache entry for multiple user IDs. + * Convenience method for the common pattern of evicting by user ID. + * + * @param cacheName The name of the cache + * @param userIds The user IDs to evict + */ + public void evictCacheForUsers(String cacheName, UUID... userIds) { + for (UUID userId : userIds) { + evictCache(cacheName, userId); + } + } + + /** + * Safely evicts multiple user IDs from multiple caches. + * Convenience method for bulk user cache eviction. + * + * @param userIds The user IDs to evict + * @param cacheNames The cache names to evict from + */ + public void evictCachesForUsers(UUID[] userIds, String... cacheNames) { + for (UUID userId : userIds) { + evictCaches(userId, cacheNames); + } + } + + /** + * Safely clears an entire cache. + * + * @param cacheName The name of the cache to clear + */ + public void clearCache(String cacheName) { + try { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.clear(); + logger.debug("Cleared cache: {}", cacheName); + } else { + logger.warn("Cache '{}' not found when attempting to clear", cacheName); + } + } catch (Exception e) { + logger.error("Error clearing cache '{}': {}", cacheName, e.getMessage()); + // Don't throw - this is a best-effort operation + } + } + + /** + * Safely clears multiple caches. + * + * @param cacheNames The names of the caches to clear + */ + public void clearCaches(String... cacheNames) { + for (String cacheName : cacheNames) { + clearCache(cacheName); + } + } + + /** + * Clears all activity-related caches. + * Convenience method that uses the predefined cache group. + */ + public void clearAllActivityCaches() { + logger.info("Clearing all activity-related caches"); + clearCaches(CacheNames.ALL_ACTIVITY_CACHES); + } + + /** + * Clears all friend request caches. + * Convenience method that uses the predefined cache group. + */ + public void clearAllFriendRequestCaches() { + logger.info("Clearing all friend request caches"); + clearCaches(CacheNames.ALL_FRIEND_REQUEST_CACHES); + } + + /** + * Clears all user-related caches. + * Convenience method that uses the predefined cache group. + */ + public void clearAllUserCaches() { + logger.info("Clearing all user-related caches"); + clearCaches(CacheNames.ALL_USER_CACHES); + } + + /** + * Clears all calendar caches. + * Convenience method that uses the predefined cache group. + */ + public void clearAllCalendarCaches() { + logger.info("Clearing all calendar caches"); + clearCaches(CacheNames.ALL_CALENDAR_CACHES); + } + + /** + * Clears all blocked user caches. + * Convenience method that uses the predefined cache group. + */ + public void clearAllBlockedUserCaches() { + logger.info("Clearing all blocked user caches"); + clearCaches(CacheNames.ALL_BLOCKED_USER_CACHES); + } + + /** + * Evicts friend-related caches for a specific user. + * This is a common operation when friend relationships change. + * + * @param userId The user ID whose friend caches should be evicted + */ + public void evictFriendCachesForUser(UUID userId) { + evictCaches(userId, + CacheNames.FRIENDS_BY_USER_ID, + CacheNames.RECOMMENDED_FRIENDS, + CacheNames.OTHER_PROFILES, + CacheNames.FRIENDS_LIST + ); + } + + /** + * Evicts friend-related caches for multiple users. + * Useful when a friendship is created/deleted affecting both users. + * + * @param userIds The user IDs whose friend caches should be evicted + */ + public void evictFriendCachesForUsers(UUID... userIds) { + for (UUID userId : userIds) { + evictFriendCachesForUser(userId); + } + } + + /** + * Evicts activity caches for a specific user. + * + * @param userId The user ID whose activity caches should be evicted + */ + public void evictActivityCachesForUser(UUID userId) { + evictCaches(userId, + CacheNames.FEED_ACTIVITIES, + CacheNames.ACTIVITIES_BY_OWNER_ID, + CacheNames.ACTIVITIES_INVITED_TO, + CacheNames.FULL_ACTIVITIES_INVITED_TO, + CacheNames.FULL_ACTIVITIES_PARTICIPATING_IN + ); + // Also clear caches that use composite keys + clearCache(CacheNames.FULL_ACTIVITY_BY_ID); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheNames.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheNames.java new file mode 100644 index 000000000..a004bb0f9 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/CacheNames.java @@ -0,0 +1,126 @@ +package com.danielagapov.spawn.shared.util; + +/** + * Central repository for all cache names used throughout the application. + * Using constants ensures consistency and prevents typos in cache name strings. + * + * This class also provides cache groups for bulk operations. + */ +public final class CacheNames { + + private CacheNames() { + // Prevent instantiation + } + + // ========== User-related caches ========== + public static final String FRIENDS_BY_USER_ID = "friendsByUserId"; + public static final String RECOMMENDED_FRIENDS = "recommendedFriends"; + public static final String USER_INTERESTS = "userInterests"; + public static final String USER_SOCIAL_MEDIA = "userSocialMedia"; + public static final String USER_SOCIAL_MEDIA_BY_USER_ID = "userSocialMediaByUserId"; + + // ========== Friend request caches ========== + public static final String INCOMING_FRIEND_REQUESTS = "incomingFetchFriendRequests"; + public static final String SENT_FRIEND_REQUESTS = "sentFetchFriendRequests"; + public static final String FRIEND_REQUESTS = "friendRequests"; + public static final String FRIEND_REQUESTS_BY_USER_ID = "friendRequestsByUserId"; + + // ========== Activity type caches ========== + public static final String ACTIVITY_TYPES = "activityTypes"; + public static final String ACTIVITY_TYPES_BY_USER_ID = "activityTypesByUserId"; + + // ========== Location caches ========== + public static final String LOCATIONS = "locations"; + public static final String LOCATION_BY_ID = "locationById"; + + // ========== Stats caches ========== + public static final String USER_STATS = "userStats"; + public static final String USER_STATS_BY_ID = "userStatsById"; + + // ========== Activity caches ========== + public static final String ACTIVITY_BY_ID = "ActivityById"; + public static final String FULL_ACTIVITY_BY_ID = "fullActivityById"; + public static final String ACTIVITY_INVITE_BY_ID = "ActivityInviteById"; + public static final String ACTIVITIES_BY_OWNER_ID = "ActivitiesByOwnerId"; + public static final String FEED_ACTIVITIES = "feedActivities"; + public static final String ACTIVITIES_INVITED_TO = "ActivitiesInvitedTo"; + public static final String FULL_ACTIVITIES_INVITED_TO = "fullActivitiesInvitedTo"; + public static final String FULL_ACTIVITIES_PARTICIPATING_IN = "fullActivitiesParticipatingIn"; + public static final String CALENDAR_ACTIVITIES = "calendarActivities"; + public static final String ALL_CALENDAR_ACTIVITIES = "allCalendarActivities"; + public static final String FILTERED_CALENDAR_ACTIVITIES = "filteredCalendarActivities"; + + // ========== Blocked user caches ========== + public static final String BLOCKED_USERS = "blockedUsers"; + public static final String BLOCKED_USER_IDS = "blockedUserIds"; + public static final String IS_BLOCKED = "isBlocked"; + + // ========== Other caches ========== + public static final String OTHER_PROFILES = "otherProfiles"; + public static final String FRIENDS_LIST = "friendsList"; + + // ========== Cache groups for bulk operations ========== + + /** + * All activity-related caches. + * Used for clearing activity caches when activities expire or are updated. + */ + public static final String[] ALL_ACTIVITY_CACHES = { + FEED_ACTIVITIES, + FULL_ACTIVITY_BY_ID, + ACTIVITY_BY_ID, + ACTIVITY_INVITE_BY_ID, + ACTIVITIES_BY_OWNER_ID, + ACTIVITIES_INVITED_TO, + FULL_ACTIVITIES_INVITED_TO, + FULL_ACTIVITIES_PARTICIPATING_IN, + CALENDAR_ACTIVITIES, + ALL_CALENDAR_ACTIVITIES, + FILTERED_CALENDAR_ACTIVITIES + }; + + /** + * All friend request related caches. + * Used for clearing friend request caches when requests are created, accepted, or rejected. + */ + public static final String[] ALL_FRIEND_REQUEST_CACHES = { + INCOMING_FRIEND_REQUESTS, + SENT_FRIEND_REQUESTS, + FRIEND_REQUESTS, + FRIEND_REQUESTS_BY_USER_ID + }; + + /** + * All user profile related caches. + * Used for clearing user caches when user data changes. + */ + public static final String[] ALL_USER_CACHES = { + FRIENDS_BY_USER_ID, + RECOMMENDED_FRIENDS, + USER_INTERESTS, + USER_SOCIAL_MEDIA, + USER_SOCIAL_MEDIA_BY_USER_ID, + OTHER_PROFILES, + FRIENDS_LIST + }; + + /** + * All calendar related caches. + * Used for clearing calendar caches when activities change. + */ + public static final String[] ALL_CALENDAR_CACHES = { + CALENDAR_ACTIVITIES, + ALL_CALENDAR_ACTIVITIES, + FILTERED_CALENDAR_ACTIVITIES + }; + + /** + * All blocked user related caches. + * Used for clearing blocked user caches when block status changes. + */ + public static final String[] ALL_BLOCKED_USER_CACHES = { + BLOCKED_USERS, + BLOCKED_USER_IDS, + IS_BLOCKED + }; +} diff --git a/src/main/java/com/danielagapov/spawn/shared/util/LocationMapper.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/LocationMapper.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/shared/util/LocationMapper.java rename to services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/LocationMapper.java diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java new file mode 100644 index 000000000..d6ccd0bef --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java @@ -0,0 +1,182 @@ +package com.danielagapov.spawn.shared.util; + +import com.danielagapov.spawn.user.api.dto.AuthResponseDTO; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.FriendUser.MinimalFriendDTO; +import com.danielagapov.spawn.user.api.dto.UserCreationDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +public final class UserMapper { + + public static BaseUserDTO toDTO(User user) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePictureUrlString(), + user.getHasCompletedOnboarding(), + null // provider not specified + ); + } + + public static BaseUserDTO toDTOWithProvider(User user, String provider) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePictureUrlString(), + user.getHasCompletedOnboarding(), + provider + ); + } + + public static AuthResponseDTO toAuthResponseDTO(User user) { + BaseUserDTO baseUserDTO = toDTO(user); + return new AuthResponseDTO(baseUserDTO, user.getStatus()); + } + + public static AuthResponseDTO toAuthResponseDTO(User user, boolean isOAuthUser) { + BaseUserDTO baseUserDTO = toDTO(user); + return new AuthResponseDTO(baseUserDTO, user.getStatus(), isOAuthUser); + } + + public static AuthResponseDTO toAuthResponseDTO(User user, boolean isOAuthUser, String provider) { + BaseUserDTO baseUserDTO = toDTOWithProvider(user, provider); + return new AuthResponseDTO(baseUserDTO, user.getStatus(), isOAuthUser); + } + + public static List toDTOList(List users) { + return users.stream().map(UserMapper::toDTO).toList(); + } + + /** + * Convert User entity to MinimalFriendDTO with only essential fields. + * This reduces memory usage when displaying friends in selection lists. + */ + public static MinimalFriendDTO toMinimalFriendDTO(User user) { + return new MinimalFriendDTO( + user.getId(), + user.getUsername(), + user.getName(), + user.getProfilePictureUrlString() + ); + } + + /** + * Convert list of User entities to MinimalFriendDTO list. + */ + public static List toMinimalFriendDTOList(List users) { + return users.stream().map(UserMapper::toMinimalFriendDTO).toList(); + } + + /** + * Convert MinimalFriendDTO to User entity (for conversion operations). + * WARNING: This creates an incomplete User entity - only id, username, name, profilePicture are set. + */ + public static User toEntity(MinimalFriendDTO dto) { + return new User( + dto.getId(), + dto.getUsername(), + dto.getProfilePicture(), + dto.getName(), + null, // bio not available in MinimalFriendDTO + null // email not available in MinimalFriendDTO + ); + } + + /** + * Convert list of MinimalFriendDTO to list of User entities. + */ + public static List toEntityList(List dtos) { + return dtos.stream() + .map(UserMapper::toEntity) + .collect(Collectors.toList()); + } + + public static UserDTO toDTO(User user, List friendUserIds) { + + return new UserDTO( + user.getId(), + friendUserIds, + user.getUsername(), + user.getProfilePictureUrlString(), + user.getName(), + user.getBio(), + user.getEmail(), + user.getHasCompletedOnboarding() + ); + } + + /** + * WARNING: This method creates a User entity with incomplete data (missing phoneNumber, status, etc.). + * It should ONLY be used for creating new users, never for updating existing ones. + * Use userService.getUserEntityById() to get complete User entities for updates. + */ + public static User toEntity(BaseUserDTO dto) { + return new User( + dto.getId(), + dto.getUsername(), + dto.getProfilePicture(), + dto.getName(), + dto.getBio(), + dto.getEmail() + ); + } + + public static List toDTOList(List users, Map> friendUserIdsMap) { + return users.stream() + .map(user -> toDTO( + user, + friendUserIdsMap.getOrDefault(user, List.of()) + )) + .collect(Collectors.toList()); + } + + public static List toEntityListFromBaseUserDTOs(List userDTOs) { + return userDTOs.stream() + .map(UserMapper::toEntity) + .collect(Collectors.toList()); + } + + public static BaseUserDTO toBaseDTO(UserDTO user) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePicture(), + user.getHasCompletedOnboarding() + ); + } + + public static List toBaseDTOList(List userDTOs) { + return userDTOs.stream() + .map(UserMapper::toBaseDTO) + .collect(Collectors.toList()); + } + + public static UserDTO toDTOFromCreationUserDTO(UserCreationDTO userCreationDTO) { + return new UserDTO( + userCreationDTO.getId(), + null, + userCreationDTO.getUsername(), + null, + userCreationDTO.getName(), + userCreationDTO.getBio(), + userCreationDTO.getEmail(), + false // Default value for new users + ); + } + +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/FullFriendUserDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/FullFriendUserDTO.java new file mode 100644 index 000000000..7eb069d44 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/FullFriendUserDTO.java @@ -0,0 +1,19 @@ +package com.danielagapov.spawn.user.api.dto.FriendUser; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class FullFriendUserDTO extends BaseUserDTO { + + public FullFriendUserDTO(UUID id, String username, String profilePicture, String name, + String bio, String email) { + super(id, name, email, username, bio, profilePicture); + } + + +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java new file mode 100644 index 000000000..62b7b237b --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java @@ -0,0 +1,58 @@ +package com.danielagapov.spawn.user.api.dto.FriendUser; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +/** + * A minimal DTO for friend users, containing only the essential fields needed + * for displaying friends in selection lists (e.g., activity creation, activity types). + * + * This DTO significantly reduces memory usage compared to FullFriendUserDTO by + * excluding fields like bio and email that are unnecessary for friend selection UIs. + * + * Fields included: + * - id: Required for selection/identification + * - username: Displayed as @username + * - name: Displayed as the friend's name + * - profilePicture: URL for avatar display + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class MinimalFriendDTO implements Serializable { + private UUID id; + private String username; + private String name; + private String profilePicture; + + /** + * Creates a MinimalFriendDTO from a FullFriendUserDTO + */ + public static MinimalFriendDTO fromFullFriendUserDTO(FullFriendUserDTO fullFriend) { + return new MinimalFriendDTO( + fullFriend.getId(), + fullFriend.getUsername(), + fullFriend.getName(), + fullFriend.getProfilePicture() + ); + } + + /** + * Creates a MinimalFriendDTO from a BaseUserDTO + */ + public static MinimalFriendDTO fromBaseUserDTO(BaseUserDTO baseUser) { + return new MinimalFriendDTO( + baseUser.getId(), + baseUser.getUsername(), + baseUser.getName(), + baseUser.getProfilePicture() + ); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/RecommendedFriendUserDTO.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/RecommendedFriendUserDTO.java new file mode 100644 index 000000000..74017ff20 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/RecommendedFriendUserDTO.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.user.api.dto.FriendUser; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.shared.util.UserRelationshipType; +import lombok.Getter; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +public class RecommendedFriendUserDTO extends BaseUserDTO { + int mutualFriendCount; + int sharedActivitiesCount; + UserRelationshipType relationshipStatus; + UUID pendingFriendRequestId; + + public RecommendedFriendUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, int mutualFriendCount, int sharedActivitiesCount, UserRelationshipType relationshipStatus, UUID pendingFriendRequestId) { + super(id, name, email, username, bio, profilePicture); + this.mutualFriendCount = mutualFriendCount; + this.sharedActivitiesCount = sharedActivitiesCount; + this.relationshipStatus = relationshipStatus; + this.pendingFriendRequestId = pendingFriendRequestId; + } + + // Constructor without relationship status for backward compatibility + public RecommendedFriendUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, int mutualFriendCount, int sharedActivitiesCount) { + this(id, name, email, username, bio, profilePicture, mutualFriendCount, sharedActivitiesCount, UserRelationshipType.RECOMMENDED_FRIEND, null); + } + + // Legacy constructor for backward compatibility + public RecommendedFriendUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, int mutualFriendCount) { + this(id, name, email, username, bio, profilePicture, mutualFriendCount, 0, UserRelationshipType.RECOMMENDED_FRIEND, null); + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java new file mode 100644 index 000000000..0b871ffb9 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java @@ -0,0 +1,72 @@ +package com.danielagapov.spawn.user.internal.domain; + +import com.danielagapov.spawn.shared.util.UserStatus; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Date; +import java.util.UUID; + +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +@Table(name = "`user`") +public class User implements Serializable { + @Id @GeneratedValue + private UUID id; + + @Column(nullable = true, unique = true) + private String username; + private String profilePictureUrlString; + + @Column(unique = true, nullable = true) + private String phoneNumber; + + @Column(nullable = true) + private String name; + private String bio; + + @Column(nullable = true, unique = true) + private String email; + private String password; + private Date dateCreated; + + @Column(name = "last_updated") + private Instant lastUpdated; + + @Column(nullable = false) + private UserStatus status; + + @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT FALSE") + private Boolean hasCompletedOnboarding = false; + + @PrePersist + public void prePersist() { + if (this.lastUpdated == null) this.lastUpdated = Instant.now(); + if (this.dateCreated == null) this.dateCreated = new Date(); + } + + @PreUpdate + public void preUpdate() { + this.lastUpdated = Instant.now(); + } + + public User(UUID id, String username, String profilePictureUrlString, String name, String bio, String email) { + this.id = id; this.username = username; this.profilePictureUrlString = profilePictureUrlString; + this.name = name; this.bio = bio; this.email = email; + this.lastUpdated = Instant.now(); this.hasCompletedOnboarding = false; + } + + public void markOnboardingCompleted() { + this.hasCompletedOnboarding = true; + } +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java new file mode 100644 index 000000000..770304ed0 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.user.internal.repositories; + +import com.danielagapov.spawn.user.internal.domain.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface IUserRepository extends JpaRepository { + Optional findByUsername(String username); + Optional findByEmail(String email); +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java new file mode 100644 index 000000000..342e9c2e5 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.user.internal.services; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.api.dto.FriendUser.FullFriendUserDTO; +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.List; +import java.util.UUID; + +public interface IUserService { + UserDTO getUserById(UUID id); + User getUserEntityById(UUID id); + BaseUserDTO getBaseUserById(UUID id); + List getFullFriendUsersByUserId(UUID requestingUserId); +} diff --git a/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java new file mode 100644 index 000000000..8fe31d8f1 --- /dev/null +++ b/services/activity-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java @@ -0,0 +1,84 @@ +package com.danielagapov.spawn.user.internal.services; + +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.util.UserMapper; +import com.danielagapov.spawn.shared.feign.MonolithUserClient; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.api.dto.FriendUser.FullFriendUserDTO; +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.user.internal.repositories.IUserRepository; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +@Service +public class UserService implements IUserService { + private final IUserRepository userRepository; + private final MonolithUserClient monolithUserClient; + private final ILogger logger; + + public UserService(IUserRepository userRepository, MonolithUserClient monolithUserClient, ILogger logger) { + this.userRepository = userRepository; + this.monolithUserClient = monolithUserClient; + this.logger = logger; + } + + @Override + public UserDTO getUserById(UUID id) { + // Try local DB first, then Feign + try { + User user = userRepository.findById(id) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User, id)); + List friendIds = Collections.emptyList(); + try { + friendIds = monolithUserClient.getFullUserById(id).getFriendUserIds(); + } catch (Exception e) { + logger.warn("Could not fetch friend IDs from monolith for user " + id + ": " + e.getMessage()); + } + return UserMapper.toDTO(user, friendIds); + } catch (BaseNotFoundException e) { + // Fall back to Feign client + try { + return monolithUserClient.getFullUserById(id); + } catch (Exception feignEx) { + throw new BaseNotFoundException(EntityType.User, id); + } + } + } + + @Override + public User getUserEntityById(UUID id) { + return userRepository.findById(id) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User, id)); + } + + @Override + public BaseUserDTO getBaseUserById(UUID id) { + try { + User user = userRepository.findById(id) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User, id)); + return UserMapper.toDTO(user); + } catch (BaseNotFoundException e) { + try { + return monolithUserClient.getUserById(id); + } catch (Exception feignEx) { + throw new BaseNotFoundException(EntityType.User, id); + } + } + } + + @Override + public List getFullFriendUsersByUserId(UUID requestingUserId) { + try { + return monolithUserClient.getFriendsByUserId(requestingUserId); + } catch (Exception e) { + logger.warn("Could not fetch friends from monolith for user " + requestingUserId + ": " + e.getMessage()); + return Collections.emptyList(); + } + } +} diff --git a/services/activity-service/src/main/resources/application.properties b/services/activity-service/src/main/resources/application.properties new file mode 100644 index 000000000..58cb4cd5f --- /dev/null +++ b/services/activity-service/src/main/resources/application.properties @@ -0,0 +1,85 @@ +spring.application.name=spawn-activity-service +server.port=8082 + +# ============================================================================ +# Database Configuration (Shared MySQL — same as monolith) +# ============================================================================ +spring.datasource.url=${MYSQL_URL} +spring.datasource.username=${MYSQL_USER} +spring.datasource.password=${MYSQL_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# HikariCP Connection Pool +spring.datasource.hikari.maximum-pool-size=10 +spring.datasource.hikari.minimum-idle=2 +spring.datasource.hikari.idle-timeout=600000 +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.connection-timeout=20000 +spring.datasource.hikari.leak-detection-threshold=60000 + +# JPA/Hibernate +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.jpa.properties.hibernate.jdbc.batch_size=25 +spring.jpa.properties.hibernate.order_inserts=true +spring.jpa.properties.hibernate.order_updates=true +spring.jpa.properties.hibernate.default_batch_fetch_size=16 + +# Flyway disabled — migrations managed by the monolith +spring.flyway.enabled=false + +# ============================================================================ +# Redis (Caching + Cross-Service Pub/Sub Events) +# ============================================================================ +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} +spring.cache.type=redis + +# Redis Connection Pooling +spring.data.redis.lettuce.pool.enabled=true +spring.data.redis.lettuce.pool.max-active=8 +spring.data.redis.lettuce.pool.max-idle=4 +spring.data.redis.lettuce.pool.min-idle=2 +spring.data.redis.lettuce.pool.max-wait=2000ms + +# Cache Configuration +spring.cache.redis.time-to-live=3600000 +spring.cache.redis.cache-names=ActivityById,fullActivityById,ActivitiesByOwnerId,feedActivities,ActivitiesInvitedTo,fullActivitiesInvitedTo,fullActivitiesParticipatingIn,calendarActivities,allCalendarActivities,filteredCalendarActivities,activityTypes,activityTypesByUserId,locations,locationById,ActivityInviteById + +# ============================================================================ +# Inter-Service Communication +# ============================================================================ +# Monolith base URL for Feign client calls (user lookup, friend data, etc.) +services.monolith.url=${MONOLITH_URL:http://localhost:8080} + +# Chat service URL (for chat messages in activity DTOs) +services.chat-service.url=${CHAT_SERVICE_URL:http://localhost:8083} + +# ============================================================================ +# Resilience4j Circuit Breaker Configuration +# ============================================================================ +resilience4j.circuitbreaker.instances.monolith.register-health-indicator=true +resilience4j.circuitbreaker.instances.monolith.sliding-window-size=10 +resilience4j.circuitbreaker.instances.monolith.minimum-number-of-calls=5 +resilience4j.circuitbreaker.instances.monolith.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.monolith.wait-duration-in-open-state=30s +resilience4j.circuitbreaker.instances.monolith.permitted-number-of-calls-in-half-open-state=3 +resilience4j.circuitbreaker.instances.monolith.automatic-transition-from-open-to-half-open-enabled=true + +resilience4j.timelimiter.instances.monolith.timeout-duration=5s + +# ============================================================================ +# Server Configuration +# ============================================================================ +server.tomcat.threads.max=50 +server.tomcat.threads.min-spare=5 +server.tomcat.max-connections=2000 +server.tomcat.connection-timeout=20000 + +# ============================================================================ +# Actuator (Health Checks & Monitoring) +# ============================================================================ +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always diff --git a/services/auth-service/.gitignore b/services/auth-service/.gitignore new file mode 100644 index 000000000..2f7896d1d --- /dev/null +++ b/services/auth-service/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/services/auth-service/.mvn/maven.config b/services/auth-service/.mvn/maven.config new file mode 100644 index 000000000..49cceca31 --- /dev/null +++ b/services/auth-service/.mvn/maven.config @@ -0,0 +1,2 @@ +# Maven configuration options +# Add Maven command-line options here, one per line diff --git a/services/auth-service/.mvn/wrapper/maven-wrapper.properties b/services/auth-service/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 000000000..d58dfb70b --- /dev/null +++ b/services/auth-service/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,19 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +wrapperVersion=3.3.2 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/services/auth-service/mvnw b/services/auth-service/mvnw new file mode 100755 index 000000000..19529ddf8 --- /dev/null +++ b/services/auth-service/mvnw @@ -0,0 +1,259 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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 +# +# http://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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.2 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + 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" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/services/auth-service/pom.xml b/services/auth-service/pom.xml new file mode 100644 index 000000000..04c9710b2 --- /dev/null +++ b/services/auth-service/pom.xml @@ -0,0 +1,202 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + com.danielagapov + spawn-auth-service + 0.0.1-SNAPSHOT + spawn-auth-service + Spawn Auth Microservice + + 17 + 1.18.42 + 2023.0.4 + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-security + + + org.springframework.boot + spring-boot-starter-mail + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + + + com.mysql + mysql-connector-j + runtime + + + com.h2database + h2 + runtime + + + org.postgresql + postgresql + runtime + + + + + org.projectlombok + lombok + provided + + + + + io.jsonwebtoken + jjwt-api + 0.12.6 + + + io.jsonwebtoken + jjwt-impl + 0.12.6 + + + io.jsonwebtoken + jjwt-jackson + 0.12.6 + + + + + com.google.api-client + google-api-client + 2.4.0 + + + + + com.auth0 + java-jwt + 4.4.0 + + + com.auth0 + jwks-rsa + 0.22.1 + + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + org.springframework.boot + spring-boot-starter-data-redis + + + + + io.github.cdimascio + dotenv-java + 3.0.2 + + + + + org.springframework.boot + spring-boot-starter-test + test + + + com.vaadin.external.google + android-json + + + + + + + central + https://repo.maven.apache.org/maven2 + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/AuthServiceApplication.java b/services/auth-service/src/main/java/com/danielagapov/spawn/AuthServiceApplication.java new file mode 100644 index 000000000..d9975aef8 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/AuthServiceApplication.java @@ -0,0 +1,47 @@ +package com.danielagapov.spawn; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; + +@SpringBootApplication +@EnableJpaRepositories +@EnableFeignClients +@EnableAsync +public class AuthServiceApplication { + public static void main(String[] args) { + + // Skip environment variable loading for test profile + String activeProfile = System.getProperty("spring.profiles.active", + System.getenv("SPRING_PROFILES_ACTIVE")); + boolean isTestProfile = "test".equals(activeProfile); + + if (!isTestProfile) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + + loadEnvVar(dotenv, "MYSQL_URL"); + loadEnvVar(dotenv, "MYSQL_USER"); + loadEnvVar(dotenv, "MYSQL_PASSWORD"); + loadEnvVar(dotenv, "EMAIL_PASS"); + loadEnvVar(dotenv, "GOOGLE_CLIENT_ID"); + loadEnvVar(dotenv, "APPLE_CLIENT_ID"); + loadEnvVar(dotenv, "SIGNING_SECRET"); + } + + SpringApplication.run(AuthServiceApplication.class, args); + } + + private static void loadEnvVar(Dotenv dotenv, String key) { + try { + String value = System.getenv(key) != null ? System.getenv(key) : dotenv.get(key); + if (value != null) { + System.setProperty(key, value); + } + } catch (NullPointerException e) { + System.err.println("Warning: " + key + " environment variable not set."); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/AuthController.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/AuthController.java new file mode 100644 index 000000000..0051fdbaf --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/AuthController.java @@ -0,0 +1,408 @@ +package com.danielagapov.spawn.auth.api; + +import com.danielagapov.spawn.auth.api.dto.CheckEmailVerificationRequestDTO; +import com.danielagapov.spawn.auth.api.dto.EmailVerificationResponseDTO; +import com.danielagapov.spawn.auth.api.dto.OAuthRegistrationDTO; +import com.danielagapov.spawn.auth.api.dto.SendEmailVerificationRequestDTO; +import com.danielagapov.spawn.user.api.dto.*; +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.exceptions.*; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.exceptions.Token.BadTokenException; +import com.danielagapov.spawn.shared.exceptions.Token.TokenNotFoundException; +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.auth.internal.services.IAuthService; +import com.danielagapov.spawn.auth.internal.services.IEmailService; +import com.danielagapov.spawn.auth.internal.services.IJWTService; +import com.danielagapov.spawn.auth.internal.services.IOAuthService; +import com.danielagapov.spawn.user.internal.services.IUserService; +import com.danielagapov.spawn.shared.util.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.servlet.ModelAndView; + +import java.util.Optional; +import java.util.UUID; + + +@RestController() +@RequestMapping("api/v1/auth") +@AllArgsConstructor +@Validated +public class AuthController { + private final IOAuthService oauthService; + private final IJWTService jwtService; + private final ILogger logger; + private final IAuthService authService; + private final IEmailService emailService; + private final IUserService userService; + + /** + * This method is meant to check whether an externally signed-in user through either Google or Apple + * already has an existing `User` created within spawn, given their external user id, which we check + * against our mappings of internal ids to external ones. + *

+ * If the user is already saved within Spawn -> we return its `BaseUserDTO`. Otherwise, null. + */ + // full path: /api/v1/auth/sign-in?externalUserId=externalUserId&email=email + @GetMapping("sign-in") + public ResponseEntity signIn( + @RequestParam(value = "idToken", required = true) String idToken, + @RequestParam(value = "provider", required = true) OAuthProvider provider, + @RequestParam(value = "email", required = false) String email) + { + try { + Optional optionalDTO = oauthService.signInUser(idToken, email, provider); + + if (optionalDTO.isPresent()) { + AuthResponseDTO authResponseDTO = optionalDTO.get(); + // Use User object for token generation to handle null usernames + User user = userService.getUserEntityById(authResponseDTO.getUser().getId()); + HttpHeaders headers = authService.makeHeadersForTokens(user); + return ResponseEntity.ok().headers(headers).body(authResponseDTO); + } + // User doesn't exist - return 404 instead of 200 with null body + logger.info("User not found during OAuth sign-in - returning 404"); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found")); + } catch (IncorrectProviderException e) { + logger.error("Incorrect provider error during sign-in: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (TokenExpiredException e) { + logger.error("Token expired during sign-in: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(e.getMessage())); + } catch (OAuthProviderUnavailableException e) { + logger.error("OAuth provider unavailable during sign-in: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE).body(new ErrorResponse(e.getMessage())); + } catch (BaseNotFoundException e) { + logger.error("Entity not found during sign-in: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(e.entityType); + } catch (SecurityException e) { + logger.error("Security error during sign-in: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Invalid token: " + e.getMessage())); + } catch (Exception e) { + logger.error("Unexpected error during sign-in: " + e.getMessage()); + return ResponseEntity.internalServerError().body(new ErrorResponse(e.getMessage())); + } + } + + + /** + * This method creates a user, given a `UserDTO` from mobile, which can be constructed through the email + * given through Google, Apple, or email/pass authentication + attributes input either by default through + * these providers, such as full name & pfp, or supplied by the user (i.e. overwritten by provider, or new). + *

+ * For profile pictures specifically, the userCreationDTO.profilePicture attribute will supply it + * to overwrite/write the profile picture to the user, by saving it to the S3Service + *

+ * Another argument is the `externalUserId`, which is a unique identifier for a user used by the external provider chosen + */ + // full path: /api/v1/auth/make-user + @PostMapping("make-user") + public ResponseEntity makeUser( + @RequestBody UserCreationDTO userCreationDTO, + @RequestParam(value = "idToken") String idToken, + @RequestParam(value = "provider") OAuthProvider provider) { + try { + BaseUserDTO user = oauthService.createUserFromOAuth(userCreationDTO, idToken, provider); + HttpHeaders headers = authService.makeHeadersForTokens(userCreationDTO.getUsername()); + return ResponseEntity.ok().headers(headers).body(user); + } catch (IllegalArgumentException e) { + logger.warn("Bad request during user creation: " + e.getMessage()); + return ResponseEntity.badRequest().body(null); + } catch (SecurityException e) { + logger.warn("Security error during user creation: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(null); + } catch (Exception e) { + logger.error("Unexpected error during user creation: " + e.getMessage()); + return ResponseEntity.internalServerError().body(null); + } + } + + // full path: /api/v1/auth/refresh-token + @PostMapping("refresh-token") + public ResponseEntity refreshToken(HttpServletRequest request) { + try { + HttpHeaders headers = new HttpHeaders(); + String token = jwtService.refreshAccessToken(request); + headers.add("Authorization", "Bearer " + token); + return ResponseEntity.ok().headers(headers).body(token); + } catch (TokenNotFoundException e) { + logger.error("No authorization token found for refresh: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body("No authorization token found"); + } catch (BadTokenException e) { + logger.error("Bad or expired token for refresh: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Bad or expired token"); + } catch (BaseNotFoundException e) { + logger.error("Entity not found during token refresh: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Entity not found: " + e.entityType); + } catch (Exception e) { + logger.error("Unexpected error during token refresh: " + e.getMessage()); + return ResponseEntity.internalServerError().body(null); + } + } + + // full path: /api/v1/auth/register + @PostMapping("register") + public ResponseEntity register(@Valid @RequestBody() AuthUserDTO authUserDTO) { + try { + UserDTO newUserDTO = authService.registerUser(authUserDTO); + HttpHeaders headers = authService.makeHeadersForTokens(newUserDTO.getUsername()); + return ResponseEntity.ok().headers(headers).body(newUserDTO); + } catch (FieldAlreadyExistsException fae) { + logger.warn("Registration failed - field already exists: " + fae.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(null); + } catch (BaseNotFoundException e) { + logger.error("Entity not found during registration: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(null); + } catch (Exception e) { + logger.error("Error registering user: " + authUserDTO.getUsername() + ": " + e.getMessage()); + return ResponseEntity.internalServerError().build(); + } + } + + // full path: /api/v1/auth/login + @PostMapping("login") + public ResponseEntity login(@RequestBody LoginDTO loginDTO) { + try { + AuthResponseDTO authResponseDTO = authService.loginUser(loginDTO.getUsernameOrEmail(), loginDTO.getPassword()); + HttpHeaders headers = authService.makeHeadersForTokens(authResponseDTO.getUser().getUsername()); + return ResponseEntity.ok().headers(headers).body(authResponseDTO); + } catch (BadCredentialsException e) { + logger.warn("Login failed - bad credentials for user: " + loginDTO.getUsernameOrEmail()); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } catch (BaseNotFoundException e) { + logger.error("Entity not found during login: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); + } catch (Exception e) { + logger.error("Error logging in user: Error: " + e.getMessage()); + return ResponseEntity.internalServerError().build(); + } + } + + // full path: /api/v1/auth/verify-email?token= + @GetMapping("verify-email") + public ModelAndView verifyEmail(@RequestParam("token") String emailToken) { + ModelAndView modelAndView = new ModelAndView(); + modelAndView.setViewName("verifyAccountPage"); + try { + boolean isVerified = authService.verifyEmail(emailToken); + String status = isVerified ? "success" : "expired"; + modelAndView.addObject("status", status); + modelAndView.setStatus(HttpStatus.OK); + return modelAndView; + } catch (BaseNotFoundException e) { + logger.error("Error verifying email: " + e.getMessage() + ", entity type: " + e.entityType); + modelAndView.addObject("status", "not_found"); + modelAndView.addObject("entityType", e.entityType); + modelAndView.setStatus(HttpStatus.NOT_FOUND); + return modelAndView; + } catch (Exception e) { + logger.error("Unexpected error while verifying email: " + e.getMessage()); + modelAndView.addObject("status", "error"); + modelAndView.setStatus(HttpStatus.INTERNAL_SERVER_ERROR); + return modelAndView; + } + } + + // New registration flow endpoints + + // full path: /api/v1/auth/register/oauth + @PostMapping("register/oauth") + public ResponseEntity registerViaOAuth(@Valid @RequestBody OAuthRegistrationDTO registration) { + try { + AuthResponseDTO user = authService.registerUserViaOAuth(registration); + // Use User object for token generation to handle null usernames + User userEntity = userService.getUserEntityById(user.getUser().getId()); + HttpHeaders headers = authService.makeHeadersForTokens(userEntity); + return ResponseEntity.ok().headers(headers).body(user); + } catch (AccountAlreadyExistsException e) { + logger.warn("OAuth registration failed - account already exists: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (FieldAlreadyExistsException e) { + logger.warn("OAuth registration failed - field already exists: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + logger.error("Error during OAuth registration: " + e.getMessage()); + // Try graceful handling if registration fails + try { + AuthResponseDTO gracefulUser = authService.handleOAuthRegistrationGracefully(registration, e); + if (gracefulUser != null) { + User userEntity = userService.getUserEntityById(gracefulUser.getUser().getId()); + HttpHeaders headers = authService.makeHeadersForTokens(userEntity); + logger.info("OAuth registration succeeded via graceful handling"); + return ResponseEntity.ok().headers(headers).body(gracefulUser); + } else { + logger.warn("Graceful handling returned null - attempting final fallback check. Email: " + registration.getEmail() + ", provider: " + registration.getProvider()); + + // Final fallback: try to sign in the user if they already exist + try { + Optional signInUser = oauthService.signInUser( + registration.getIdToken(), + registration.getEmail(), + registration.getProvider() + ); + + if (signInUser.isPresent()) { + User userEntity = userService.getUserEntityById(signInUser.get().getUser().getId()); + HttpHeaders headers = authService.makeHeadersForTokens(userEntity); + logger.info("OAuth registration succeeded via final fallback sign-in"); + return ResponseEntity.ok().headers(headers).body(signInUser.get()); + } + } catch (Exception fallbackEx) { + logger.warn("Final fallback sign-in also failed: " + fallbackEx.getMessage()); + } + } + } catch (Exception gracefulException) { + logger.error("Graceful handling also failed: " + gracefulException.getMessage()); + } + + // If all recovery attempts failed, return detailed error + logger.error("All OAuth registration attempts failed for email: " + registration.getEmail()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("Registration failed. Please try again or contact support if the issue persists.")); + } + } + + // full path: /api/v1/auth/register/send + @PostMapping("register/verification/send") + public ResponseEntity sendEmailVerificationForRegistration(@RequestBody SendEmailVerificationRequestDTO request) { + try { + EmailVerificationResponseDTO response = authService.sendEmailVerificationCodeForRegistration(request.getEmail()); + return ResponseEntity.ok().body(response); + } catch (FieldAlreadyExistsException e) { + logger.warn("Email already exists during registration: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + logger.error("Error sending email verification for registration: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to send verification code")); + } + } + + // full path: /api/v1/auth/register/verification/check + @PostMapping("register/verification/check") + public ResponseEntity verifyEmailAndCreateUser(@RequestBody CheckEmailVerificationRequestDTO request) { + try { + AuthResponseDTO authResponseDTO = authService.checkEmailVerificationCode(request.getEmail(), request.getVerificationCode()); + // Use User object for token generation to handle users with null usernames + HttpHeaders headers = authService.makeHeadersForTokens(authResponseDTO.getUser().getUsername()); + return ResponseEntity.ok().headers(headers).body(authResponseDTO); + } catch (EmailVerificationException e) { + logger.warn("Email verification failed: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(new ErrorResponse(e.getMessage())); + } catch (FieldAlreadyExistsException e) { + logger.warn("User creation failed - field already exists: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + logger.error("Error verifying email and creating user: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to verify email and create user")); + } + } + + // full path: /api/v1/auth/change-password + @PostMapping("change-password") + public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwordChangeDTO, HttpServletRequest request) { + try { + // Extract username from JWT token + final String authHeader = request.getHeader("Authorization"); + final String token = authHeader.substring(7); + final String username = jwtService.extractUsername(token); + + boolean success = authService.changePassword( + username, + passwordChangeDTO.getCurrentPassword(), + passwordChangeDTO.getNewPassword() + ); + + if (success) { + return ResponseEntity.ok().build(); + } else { + logger.warn("Password change failed for user: " + username); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse("Current password is incorrect")); + } + } catch (Exception e) { + logger.error("Error changing password: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to change password")); + } + } + + // full path: /api/v1/auth/quick-sign-in + @GetMapping("quick-sign-in") + public ResponseEntity quickSignIn(HttpServletRequest request) { + try { + AuthResponseDTO authResponse = authService.getUserByToken(request.getHeader("Authorization").substring(7)); + return new ResponseEntity<>(authResponse, HttpStatus.OK); + } catch (Exception e) { + logger.error("Error retrieving user: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Error while performing quick sign-in")); + } + } + + // full path: /api/v1/auth/user/details + @PostMapping("user/details") + public ResponseEntity updateUserDetails(@Valid @RequestBody UpdateUserDetailsDTO dto) { + try { + BaseUserDTO updatedUser = authService.updateUserDetails(dto); + // Use User object for token generation to handle cases where username was just set + User user = userService.getUserEntityById(updatedUser.getId()); + HttpHeaders headers = authService.makeHeadersForTokens(user); + return ResponseEntity.ok().headers(headers).body(updatedUser); + } catch (BaseNotFoundException e) { + logger.error("User not found for update: " + dto.getId() + ", entity type: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found")); + } catch (FieldAlreadyExistsException e) { + logger.warn("Username already exists: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(e.getMessage())); + } catch (Exception e) { + logger.error("Error updating user details: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to update user details")); + } + } + + // full path: /api/v1/auth/complete-contact-import/{userId} + @PostMapping("complete-contact-import/{userId}") + public ResponseEntity completeContactImport(@PathVariable UUID userId) { + try { + BaseUserDTO updatedUser = authService.completeContactImport(userId); + return ResponseEntity.ok(updatedUser); + } catch (BaseNotFoundException e) { + logger.error("User not found for contact import completion: " + userId + ", entity type: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found")); + } catch (Exception e) { + logger.error("Error completing contact import for user: " + userId + ": " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to complete contact import")); + } + } + + // full path: /api/v1/auth/accept-tos/{userId} + @PostMapping("accept-tos/{userId}") + public ResponseEntity acceptTermsOfService(@PathVariable UUID userId) { + try { + BaseUserDTO updatedUser = authService.acceptTermsOfService(userId); + return ResponseEntity.ok(updatedUser); + } catch (BaseNotFoundException e) { + logger.error("User not found for TOS acceptance: " + userId + ", entity type: " + e.entityType); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("User not found")); + } catch (Exception e) { + logger.error("Error accepting Terms of Service for user: " + userId + ": " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse("Failed to accept Terms of Service")); + } + } + + /* ------------------------------ HELPERS ------------------------------ */ + + @Deprecated(since = "For testing purposes") + @GetMapping("test-email") + public ResponseEntity email() { + // Email is sent asynchronously - errors are logged by the email service + emailService.sendEmail("spawnappmarketing@gmail.com", "Test Email", "This is a test email sent programmatically."); + return ResponseEntity.ok().body("Email queued for sending"); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/CheckEmailVerificationRequestDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/CheckEmailVerificationRequestDTO.java new file mode 100644 index 000000000..4c6b30405 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/CheckEmailVerificationRequestDTO.java @@ -0,0 +1,13 @@ +package com.danielagapov.spawn.auth.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class CheckEmailVerificationRequestDTO { + private String email; + private String verificationCode; +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/EmailVerificationResponseDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/EmailVerificationResponseDTO.java new file mode 100644 index 000000000..71c8cd097 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/EmailVerificationResponseDTO.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.auth.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class EmailVerificationResponseDTO implements Serializable { + private long secondsUntilNextAttempt; + private String message; +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/OAuthRegistrationDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/OAuthRegistrationDTO.java new file mode 100644 index 000000000..b7a8da22f --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/OAuthRegistrationDTO.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.auth.api.dto; + +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.validation.ValidName; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class OAuthRegistrationDTO { + @NotBlank(message = "Email is required") + @Email(message = "Email must be valid") + private String email; + + @NotBlank(message = "ID token is required") + private String idToken; // Changed from externalIdToken to idToken + + @NotNull(message = "OAuth provider is required") + private OAuthProvider provider; + + @ValidName(optional = true) + private String name; + + private String profilePictureUrl; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/SendEmailVerificationRequestDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/SendEmailVerificationRequestDTO.java new file mode 100644 index 000000000..3daeef28f --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/api/dto/SendEmailVerificationRequestDTO.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.auth.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class SendEmailVerificationRequestDTO implements Serializable { + private String email; +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/EmailVerification.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/EmailVerification.java new file mode 100644 index 000000000..7a8b34abc --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/EmailVerification.java @@ -0,0 +1,51 @@ +package com.danielagapov.spawn.auth.internal.domain; + +import com.danielagapov.spawn.user.internal.domain.User; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Getter +@Setter +public class EmailVerification { + @Id + @GeneratedValue + private UUID id; + + private int sendAttempts = 0; + private Instant lastSendAttemptAt; + private Instant nextSendAttemptAt; + + private int checkAttempts = 0; + private Instant lastCheckAttemptAt; + private Instant nextCheckAttemptAt; + + @Column(unique = true, nullable = false) + private String verificationCode; + @Column(unique = true) + private String email; + private Instant codeExpiresAt; + + @OneToOne(fetch = FetchType.LAZY) + private User user; + + @PrePersist + public void onCreate() { + if (lastSendAttemptAt == null) { + lastSendAttemptAt = Instant.now(); + } + if (nextSendAttemptAt == null) { + nextSendAttemptAt = Instant.now().plusSeconds(30); + } + } + + @PreUpdate + public void onUpdate() { + lastSendAttemptAt = Instant.now(); + lastCheckAttemptAt = Instant.now(); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/UserIdExternalIdMap.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/UserIdExternalIdMap.java new file mode 100644 index 000000000..52c4eaf92 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/domain/UserIdExternalIdMap.java @@ -0,0 +1,36 @@ +package com.danielagapov.spawn.auth.internal.domain; + +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.user.internal.domain.User; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.OnDelete; +import org.hibernate.annotations.OnDeleteAction; + +/** + * This maps an external user id (from Apple or Google) + * to a spawn's user id, so we can keep track. + * For now, we're limiting spawn accounts to just one + * external mapping. So, if you create an account through + * Google and make a corresponding Spawn user, your Apple + * account must link to a new Spawn user. + */ +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class UserIdExternalIdMap { + @Id + private String id; // the id (or sub) from external provider like Google OAuth + + // TODO: may need to revisit relationship type if google/apple calendars is a feature later + @OneToOne + @JoinColumn(name = "user_id", referencedColumnName = "id", nullable = false) + @OnDelete(action = OnDeleteAction.CASCADE) + private User user; + + @Enumerated(EnumType.STRING) + private OAuthProvider provider; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IEmailVerificationRepository.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IEmailVerificationRepository.java new file mode 100644 index 000000000..f869ec773 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IEmailVerificationRepository.java @@ -0,0 +1,17 @@ +package com.danielagapov.spawn.auth.internal.repositories; + +import com.danielagapov.spawn.auth.internal.domain.EmailVerification; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.UUID; + +@Repository +public interface IEmailVerificationRepository extends JpaRepository { + + EmailVerification findByEmail(String email); + + boolean existsByEmail(String email); + + boolean existsByVerificationCode(String verificationCode); +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IUserIdExternalIdMapRepository.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IUserIdExternalIdMapRepository.java new file mode 100644 index 000000000..23ca85218 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/repositories/IUserIdExternalIdMapRepository.java @@ -0,0 +1,27 @@ +package com.danielagapov.spawn.auth.internal.repositories; + +import com.danielagapov.spawn.auth.internal.domain.UserIdExternalIdMap; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface IUserIdExternalIdMapRepository extends JpaRepository { + Optional findByUserEmail(final String userEmail); + + @Modifying + @Transactional + @Query("DELETE FROM UserIdExternalIdMap m WHERE m.user.id = :userId") + void deleteAllByUserId(@Param("userId") UUID userId); + + boolean existsByUserId(UUID userId); + + UserIdExternalIdMap findByUserId(UUID userId); + +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AppleOAuthStrategy.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AppleOAuthStrategy.java new file mode 100644 index 000000000..1042e9359 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AppleOAuthStrategy.java @@ -0,0 +1,132 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.auth0.jwk.Jwk; +import com.auth0.jwk.JwkProvider; +import com.auth0.jwk.JwkProviderBuilder; +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.interfaces.DecodedJWT; +import com.auth0.jwt.interfaces.JWTVerifier; +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.exceptions.TokenExpiredException; +import com.danielagapov.spawn.shared.exceptions.OAuthProviderUnavailableException; +import com.danielagapov.spawn.shared.util.RetryHelper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.security.PublicKey; +import java.security.interfaces.RSAPublicKey; +import java.util.concurrent.TimeUnit; + +@Service +public final class AppleOAuthStrategy implements OAuthStrategy { + private final ILogger logger; + + private final JwkProvider appleJwkProvider; + + private static final String APPLE_JWKS_URL = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + + @Value("${apple.client.id}") + private String appleClientId; + + @Autowired + public AppleOAuthStrategy(ILogger logger) { + this.logger = logger; + + // Initialize the Apple JWK provider + this.appleJwkProvider = new JwkProviderBuilder(APPLE_JWKS_URL) + .cached(10, 24, TimeUnit.HOURS) // Cache up to 10 JWKs for 24 hours + .rateLimited(10, 1, TimeUnit.MINUTES) // Max 10 requests per minute + .build(); + } + + @Override + public OAuthProvider getOAuthProvider() { + return OAuthProvider.apple; + } + + @Override + public String verifyIdToken(String idToken) { + try { + logger.info("Verifying Apple ID token"); + + if (idToken == null || idToken.isEmpty()) { + throw new SecurityException("Empty Apple ID token provided"); + } + + // Use retry helper for token verification + return RetryHelper.executeOAuthWithRetry(() -> { + try { + // Parse the JWT without verifying to get the header and extract the Key ID + DecodedJWT decodedJWT = JWT.decode(idToken); + + // Check token expiration before verification + if (decodedJWT.getExpiresAt() != null && decodedJWT.getExpiresAt().before(new java.util.Date())) { + logger.error("Apple ID token has expired. Expiration: " + decodedJWT.getExpiresAt() + ", Current time: " + new java.util.Date()); + throw new TokenExpiredException("Apple ID token has expired, please sign in again"); + } + + // Get the kid (Key ID) from the JWT header + String keyId = decodedJWT.getKeyId(); + if (keyId == null) { + throw new SecurityException("Key ID not found in Apple ID token header"); + } + + // Get the matching JWK from Apple's JWKS endpoint using the Key ID + Jwk jwk = appleJwkProvider.get(keyId); + + // Get the public key from the JWK + PublicKey publicKey = jwk.getPublicKey(); + + // Create a verification algorithm using the public key + Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) publicKey, null); + + // Create a verifier for Apple ID tokens + JWTVerifier verifier = JWT.require(algorithm) + .withIssuer(APPLE_ISSUER) + .withAudience(appleClientId) + .build(); + + // Verify the token + DecodedJWT verifiedJWT = verifier.verify(idToken); + + // Extract the subject (user ID) + String userId = verifiedJWT.getSubject(); + logger.info("Successfully verified Apple ID token and extracted user ID: " + userId); + + return userId; + + } catch (com.auth0.jwt.exceptions.TokenExpiredException e) { + logger.error("Apple ID token has expired: " + e.getMessage()); + throw new TokenExpiredException("Apple ID token has expired, please sign in again"); + } catch (com.auth0.jwt.exceptions.JWTVerificationException e) { + logger.error("Apple ID token verification failed: " + e.getMessage()); + throw new SecurityException("Apple ID token verification failed: " + e.getMessage(), e); + } catch (com.auth0.jwk.JwkException e) { + logger.error("Error retrieving Apple's public key: " + e.getMessage()); + // Check if it's a network-related error + if (e.getCause() instanceof java.net.ConnectException || + e.getCause() instanceof java.net.SocketTimeoutException || + e.getCause() instanceof java.io.IOException) { + throw new OAuthProviderUnavailableException("Apple authentication service is temporarily unavailable. Please try again later.", e); + } else { + throw new SecurityException("Failed to retrieve Apple's public key: " + e.getMessage(), e); + } + } + }); + + } catch (TokenExpiredException e) { + logger.error("Token expired: " + e.getMessage()); + throw e; + } catch (OAuthProviderUnavailableException e) { + logger.error("OAuth provider unavailable: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error verifying Apple ID token: " + e.getMessage()); + throw new SecurityException("Unexpected error verifying Apple ID token: " + e.getMessage(), e); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AuthService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AuthService.java new file mode 100644 index 000000000..e2c510fa3 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/AuthService.java @@ -0,0 +1,779 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.danielagapov.spawn.auth.api.dto.EmailVerificationResponseDTO; +import com.danielagapov.spawn.auth.api.dto.OAuthRegistrationDTO; +import com.danielagapov.spawn.user.api.dto.*; +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.util.UserField; +import com.danielagapov.spawn.shared.util.UserStatus; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.*; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.util.UserMapper; +import com.danielagapov.spawn.auth.internal.domain.EmailVerification; +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.auth.internal.repositories.IEmailVerificationRepository; +import com.danielagapov.spawn.auth.internal.services.IEmailService; +import com.danielagapov.spawn.auth.internal.services.IJWTService; +import com.danielagapov.spawn.auth.internal.services.IOAuthService; +import com.danielagapov.spawn.media.internal.services.S3Service; +import com.danielagapov.spawn.user.internal.services.IUserService; +import com.danielagapov.spawn.shared.util.LoggingUtils; +import com.danielagapov.spawn.shared.util.PhoneNumberValidator; +import com.danielagapov.spawn.shared.util.VerificationCodeGenerator; +import lombok.AllArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +@Service +@AllArgsConstructor +public class AuthService implements IAuthService { + private final IUserService userService; + private final IJWTService jwtService; + private final IEmailService emailService; + private final PasswordEncoder passwordEncoder; + private final AuthenticationManager authenticationManager; + private final ILogger logger; + private final IOAuthService oauthService; + private final IEmailVerificationRepository emailVerificationRepository; + + + @Override + public UserDTO registerUser(AuthUserDTO authUserDTO) throws FieldAlreadyExistsException { + logger.info("Attempting to register new user with username: " + authUserDTO.getUsername()); + + // Validate input fields + if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidUsername(authUserDTO.getUsername())) { + throw new IllegalArgumentException("Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)"); + } + if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidEmail(authUserDTO.getEmail())) { + throw new IllegalArgumentException("Email must be valid"); + } + if (authUserDTO.getName() != null && !authUserDTO.getName().isEmpty() && + !com.danielagapov.spawn.shared.util.InputValidationUtil.isValidName(authUserDTO.getName())) { + throw new IllegalArgumentException("Name must be 1-100 characters and contain only letters, spaces, hyphens, and apostrophes"); + } + + checkIfUniqueCredentials(authUserDTO); + try { + UserDTO userDTO = createAndSaveUser(authUserDTO); + User user = UserMapper.toEntity(userDTO); + logger.info("User registered successfully: " + LoggingUtils.formatUserInfo(user)); + createEmailTokenAndSendEmail(authUserDTO); + return userDTO; + } catch (Exception e) { + logger.error("Unexpected error while registering user with username: " + authUserDTO.getUsername() + ": " + e.getMessage()); + throw e; + } + } + + @Override + public AuthResponseDTO loginUser(String usernameOrEmail, String password) { + String username; + final String errorMsg = "Incorrect username, email, or password"; + + if (usernameOrEmail == null || usernameOrEmail.isBlank() || password == null || password.isBlank()) { + throw new IllegalArgumentException("Username, email, and password must be provided"); + } + + User user = null; + if (usernameOrEmail.contains("@")) { // This is an email + user = userService.getUserByEmail(usernameOrEmail); + if (user == null) { + throw new BadCredentialsException(errorMsg); + } + + username = user.getUsername(); + } else { // This is a username + username = usernameOrEmail; + } + + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password) + ); + + if (authentication.isAuthenticated()) { + String authenticatedUsername = ((UserDetails) authentication.getPrincipal()).getUsername(); + + if (user == null) { + user = userService.getUserEntityByUsername(authenticatedUsername); + } + + return UserMapper.toAuthResponseDTO(user); + } + throw new BadCredentialsException(errorMsg); + } + + @Override + public boolean verifyEmail(String token) { + try { + if (jwtService.isValidEmailToken(token)) { + // The email token is valid so mark this user as verified user in database + final String username = jwtService.extractUsername(token); + logger.info("Verifying email for user with username: " + username); + + User user = userService.getUserEntityByUsername(username); + userService.saveEntity(user); + + logger.info("Email verified successfully for user: " + LoggingUtils.formatUserInfo(user)); + return true; + } + logger.warn("Invalid email verification token received. Token prefix: " + (token != null ? token.substring(0, Math.min(20, token.length())) + "..." : "null")); + return false; + } catch (Exception e) { + logger.error("Error during email verification: " + e.getMessage()); + return false; + } + } + + @Override + public boolean changePassword(String username, String currentPassword, String newPassword) { + try { + logger.info("Attempting to change password for user: " + username); + + // Verify current password by attempting authentication + try { + authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, currentPassword) + ); + } catch (BadCredentialsException e) { + logger.warn("Current password verification failed for user: " + username); + return false; + } + + // Get user and update password + User user = userService.getUserEntityByUsername(username); + user.setPassword(passwordEncoder.encode(newPassword)); + userService.saveEntity(user); + + logger.info("Password successfully changed for user: " + username); + return true; + } catch (Exception e) { + logger.error("Error changing password for user: " + username + ": " + e.getMessage()); + return false; + } + } + + @Override + public AuthResponseDTO getUserByToken(String token) { + final String username = jwtService.extractUsername(token); + User user = userService.getUserEntityByUsername(username); + + // Determine the auth provider for this user + boolean isOAuthUser = oauthService.isOAuthUser(user.getId()); + String provider; + if (isOAuthUser) { + try { + OAuthProvider oauthProvider = oauthService.getOAuthProvider(user.getId()); + provider = oauthProvider.name(); // "google" or "apple" + } catch (Exception e) { + logger.warn("Could not determine OAuth provider for user: " + user.getId() + ". " + e.getMessage()); + provider = null; + } + } else { + provider = "email"; + } + + return UserMapper.toAuthResponseDTO(user, isOAuthUser, provider); + } + + @Override + public BaseUserDTO updateUserDetails(UpdateUserDetailsDTO dto) { + if (dto.getId() == null || dto.getUsername() == null || dto.getPhoneNumber() == null) { + throw new IllegalArgumentException("User ID, username, and phone number cannot be null"); + } + + // Validate username format + if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidUsername(dto.getUsername())) { + throw new IllegalArgumentException("Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)"); + } + + // Validate phone number format + if (!com.danielagapov.spawn.shared.util.InputValidationUtil.isValidPhoneNumber(dto.getPhoneNumber())) { + throw new IllegalArgumentException("Phone number must be in valid E.164 format"); + } + + User user = userService.getUserEntityById(dto.getId()); + if (user == null) { + throw new BaseNotFoundException(EntityType.User); + } + + if (user.getStatus() != UserStatus.EMAIL_VERIFIED) { + throw new RuntimeException("Cannot update user details before email is verified"); + } + + // Check for username uniqueness if changed + String currentUsername = user.getOptionalUsername().orElse(""); + if (!dto.getUsername().equals(currentUsername)) { + if (userService.existsByUsername(dto.getUsername())) { + throw new FieldAlreadyExistsException("Username already exists", UserField.USERNAME); + } + user.setUsername(dto.getUsername()); + // Don't automatically set name to username - let user choose their display name + } + + // Clean and validate phone number before storing + String currentPhone = user.getOptionalPhoneNumber().orElse(""); + + // Only validate and update if the phone number is actually being changed + // This prevents errors when client sends corrupted cached data that matches current value + if (dto.getPhoneNumber() != null && !dto.getPhoneNumber().equals(currentPhone)) { + String cleanedPhoneNumber = PhoneNumberValidator.cleanPhoneNumber(dto.getPhoneNumber()); + if (cleanedPhoneNumber == null || cleanedPhoneNumber.trim().isEmpty()) { + logger.warn("Invalid phone number format for user " + LoggingUtils.formatUserIdInfo(user.getId()) + + ". Received: '" + dto.getPhoneNumber() + "', cleaned result: " + + (cleanedPhoneNumber == null ? "null" : "'" + cleanedPhoneNumber + "'")); + throw new IllegalArgumentException("Invalid phone number format: " + dto.getPhoneNumber()); + } + + // Check if the cleaned phone number already exists + if (!cleanedPhoneNumber.equals(currentPhone)) { + if (userService.existsByPhoneNumber(cleanedPhoneNumber)) { + throw new PhoneNumberAlreadyExistsException("Phone number already exists"); + } + user.setPhoneNumber(cleanedPhoneNumber); + logger.info("Updated phone number for user: " + LoggingUtils.formatUserIdInfo(user.getId()) + + " from '" + currentPhone + "' to '" + cleanedPhoneNumber + "'"); + } + } + + // Update password if provided + if (dto.getPassword() != null && !dto.getPassword().isEmpty() && user.getOptionalPassword().isEmpty()) { + user.setPassword(passwordEncoder.encode(dto.getPassword())); + } + + user.setStatus(UserStatus.USERNAME_AND_PHONE_NUMBER); + userService.saveEntity(user); + return UserMapper.toDTO(user); + } + + + @Override + @Transactional + public AuthResponseDTO registerUserViaOAuth(OAuthRegistrationDTO registrationDTO) { + String email = registrationDTO.getEmail(); + String idToken = registrationDTO.getIdToken(); + OAuthProvider provider = registrationDTO.getProvider(); + + if (email == null && idToken == null) { + throw new IllegalArgumentException("Email and idToken cannot be null for OAuth registration"); + } + + // Simplified retry logic since OAuthService now handles concurrency properly + int maxRetries = 2; + for (int attempt = 1; attempt <= maxRetries; attempt++) { + try { + return registerUserViaOAuthInternal(registrationDTO, email, idToken, provider, attempt, maxRetries); + } catch (org.springframework.dao.DataIntegrityViolationException | + org.springframework.dao.OptimisticLockingFailureException | + org.hibernate.StaleObjectStateException e) { + + logger.warn("Concurrent OAuth registration detected on attempt " + attempt + "/" + maxRetries + + " for email: " + email + ". " + e.getMessage()); + + if (attempt == maxRetries) { + logger.error("Failed to complete OAuth registration after " + maxRetries + + " attempts due to concurrent modifications"); + + // Try to return existing user if created by concurrent request + try { + String externalId = oauthService.checkOAuthRegistration(email, idToken, provider); + Optional existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (existingUser.isPresent()) { + logger.info("Returning user created by concurrent request"); + return existingUser.get(); + } + } catch (Exception checkEx) { + logger.warn("Could not check for existing user after failed registration: " + checkEx.getMessage()); + } + + throw new RuntimeException("Unable to process OAuth registration due to concurrent modifications. Please try again."); + } + + // Brief wait before retry + try { + Thread.sleep(100 * attempt); // Progressive backoff + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during OAuth registration retry"); + } + } + } + + throw new RuntimeException("OAuth registration failed after all retry attempts"); + } + + private AuthResponseDTO registerUserViaOAuthInternal(OAuthRegistrationDTO registrationDTO, String email, + String idToken, OAuthProvider provider, int attempt, int maxRetries) { + // Verify OAuth token and get external ID + // Note: checkOAuthRegistration now handles incomplete users with proper synchronization + String externalId = oauthService.checkOAuthRegistration(email, idToken, provider); + + // Check if user already exists and is ACTIVE - if so, redirect to sign-in behavior + Optional existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (existingUser.isPresent()) { + AuthResponseDTO authResponse = existingUser.get(); + logger.info(authResponse.getStatus() + " user attempting to register - returning existing user: " + authResponse.getUser().getEmail()); + return authResponse; + } + + // Create verified user immediately for OAuth + User newUser = new User(); + newUser.setEmail(email); + // Leave username and phoneNumber as null - user will provide them during onboarding + newUser.setUsername(null); + newUser.setPhoneNumber(null); + + // Use provided name from OAuth or fallback to email prefix + String providedName = registrationDTO.getName(); + if (providedName != null && !providedName.trim().isEmpty()) { + newUser.setName(providedName.trim()); + } else if (email != null) { + // Fallback to email prefix as initial name + newUser.setName(email.split("@")[0]); + } + + newUser.setStatus(UserStatus.EMAIL_VERIFIED); + newUser.setDateCreated(new Date()); + + String profilePictureUrl = registrationDTO.getProfilePictureUrl(); + newUser.setProfilePictureUrlString(profilePictureUrl == null ? S3Service.getDefaultProfilePictureUrlString() : profilePictureUrl); + + logger.info(String.format("Creating OAuth user on attempt %d/%d: %s", attempt, maxRetries, email)); + + try { + // Create user and mapping in a transaction + newUser = userService.createAndSaveUser(newUser); + oauthService.createAndSaveMapping(newUser, externalId, provider); + + logger.info("OAuth user registered successfully: " + LoggingUtils.formatUserInfo(newUser)); + return UserMapper.toAuthResponseDTO(newUser); + } catch (Exception e) { + logger.error("Failed to create OAuth user and mapping: " + e.getMessage()); + // Clean up user if it was created but mapping failed + if (newUser.getId() != null) { + try { + userService.deleteUserById(newUser.getId()); + logger.info("Cleaned up partially created user after mapping failure"); + } catch (Exception cleanupEx) { + logger.warn("Failed to clean up partially created user: " + cleanupEx.getMessage()); + } + } + throw e; + } + } + + @Override + public AuthResponseDTO handleOAuthRegistrationGracefully(OAuthRegistrationDTO registrationDTO, Exception exception) { + String email = registrationDTO.getEmail(); + String idToken = registrationDTO.getIdToken(); + OAuthProvider provider = registrationDTO.getProvider(); + + logger.info("Attempting graceful OAuth registration recovery for email: " + email + " due to exception: " + exception.getMessage()); + + try { + // Verify OAuth token to get external ID + String externalId; + try { + externalId = oauthService.checkOAuthRegistration(email, idToken, provider); + } catch (Exception e) { + logger.warn("Could not verify OAuth token in graceful handler: " + e.getMessage()); + return null; + } + + // Perform data consistency cleanup before attempting recovery + logger.info("Performing data consistency cleanup before graceful recovery"); + try { + boolean cleanupPerformed = oauthService.performDataConsistencyCleanup(email, externalId); + + if (cleanupPerformed) { + logger.info("Data cleanup performed, waiting briefly for cleanup to complete"); + Thread.sleep(100); // Brief wait for cleanup to complete + } + } catch (Exception cleanupEx) { + logger.warn("Could not perform data consistency cleanup: " + cleanupEx.getMessage()); + } + + // Check if an existing user can be found and returned after cleanup + Optional existingUser = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (existingUser.isPresent()) { + logger.info("Found existing user after cleanup in graceful handler, returning user data"); + return existingUser.get(); + } + + // For data integrity violations that suggest concurrent creation, + // give other threads a moment to complete and then check again + if (exception instanceof org.springframework.dao.DataIntegrityViolationException || + exception instanceof org.hibernate.StaleObjectStateException) { + logger.info("Concurrency-related exception detected, checking for concurrent user creation"); + + try { + Thread.sleep(300); // Longer wait for concurrent operations to complete + + // Re-check for existing user after wait + Optional concurrentUser = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (concurrentUser.isPresent()) { + logger.info("Found user created by concurrent thread after concurrency exception"); + return concurrentUser.get(); + } + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + logger.warn("Interrupted while waiting to check for concurrent user creation: " + ie.getMessage()); + } catch (Exception recheckEx) { + logger.warn("Error during concurrent user re-check: " + recheckEx.getMessage()); + } + } + + // If no existing user found and this is not a recoverable scenario, + // attempt to create a minimal user for graceful degradation + logger.info("Attempting graceful user creation for external ID: " + externalId); + + User newUser = new User(); + newUser.setEmail(email); + newUser.setUsername(null); // Will be set during onboarding + newUser.setPhoneNumber(null); // Will be set during onboarding + newUser.setStatus(UserStatus.EMAIL_VERIFIED); + newUser.setDateCreated(new Date()); + + String profilePictureUrl = registrationDTO.getProfilePictureUrl(); + newUser.setProfilePictureUrlString(profilePictureUrl == null ? S3Service.getDefaultProfilePictureUrlString() : profilePictureUrl); + + String providedName = registrationDTO.getName(); + if (providedName != null && !providedName.trim().isEmpty()) { + newUser.setName(providedName.trim()); + } else { + newUser.setName(email.split("@")[0]); + } + + // Try graceful user creation with additional error handling + try { + newUser = userService.createAndSaveUser(newUser); + oauthService.createAndSaveMapping(newUser, externalId, provider); + + logger.info("OAuth user created gracefully with EMAIL_VERIFIED status: " + LoggingUtils.formatUserInfo(newUser)); + return UserMapper.toAuthResponseDTO(newUser); + } catch (Exception createEx) { + logger.warn("Failed to create user gracefully, performing final checks: " + createEx.getMessage()); + + // Final comprehensive check for concurrent user creation + try { + // Wait a bit longer and try multiple approaches to find the user + Thread.sleep(200); + + // Try by external ID first + Optional finalCheck = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (finalCheck.isPresent()) { + logger.info("Found user created by another thread during graceful creation attempt"); + return finalCheck.get(); + } + + // Try one more data consistency cleanup and check + boolean cleanupPerformed = oauthService.performDataConsistencyCleanup(email, externalId); + + // Final check after cleanup + finalCheck = oauthService.getUserIfExistsbyExternalId(externalId, email); + if (finalCheck.isPresent()) { + logger.info("Found user after final cleanup in graceful handler"); + return finalCheck.get(); + } + + } catch (Exception finalEx) { + logger.warn("Error during final comprehensive user check: " + finalEx.getMessage()); + } + + // If we still can't create or find the user, return null to let the caller handle it + logger.error("Graceful handling failed completely for email: " + email); + return null; + } + + } catch (Exception e) { + logger.error("Failed to handle OAuth registration gracefully: " + e.getMessage()); + return null; + } + } + + @Override + public HttpHeaders makeHeadersForTokens(String username) { + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtService.generateAccessToken(username)); + headers.set("X-Refresh-Token", jwtService.generateRefreshToken(username)); + return headers; + } + + /** + * Helper method to generate tokens for users, using email as fallback when username is null + * This is specifically needed for OAuth users during onboarding who don't have usernames yet + */ + public HttpHeaders makeHeadersForTokens(User user) { + String subject = user.getOptionalUsername().orElse(user.getEmail()); + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "Bearer " + jwtService.generateAccessToken(subject)); + headers.set("X-Refresh-Token", jwtService.generateRefreshToken(subject)); + return headers; + } + + @Override + public EmailVerificationResponseDTO sendEmailVerificationCodeForRegistration(String email) { + if (email == null || email.trim().isEmpty()) { + throw new IllegalArgumentException("Email cannot be null or empty"); + } + + // Check if user already exists + if (userService.existsByEmail(email)) { + User user = userService.getUserByEmail(email); + if ( user != null && user.getStatus() == UserStatus.ACTIVE) { + throw new EmailAlreadyExistsException("Email already exists"); + } + if (user != null && oauthService.isOAuthUser(user.getId())) { + throw new EmailAlreadyExistsException("An account with this email was already created with " + oauthService.getOAuthProvider(user.getId()).toString() + " authentication"); + } + } + + EmailVerification verification; + long secondsUntilNextAttempt; + + // Check for existing verification record for this email + if (emailVerificationRepository.existsByEmail(email)) { + verification = emailVerificationRepository.findByEmail(email); + + // Check if we need to wait before sending another code + if (verification.getNextSendAttemptAt().isAfter(Instant.now())) { + long secondsToWait = Duration.between(Instant.now(), verification.getNextSendAttemptAt()).getSeconds(); + return new EmailVerificationResponseDTO(secondsToWait, "Please wait before requesting another verification code"); + } + + // Update attempt count and calculate next timeout + verification.setSendAttempts(verification.getSendAttempts() + 1); + secondsUntilNextAttempt = getSendVerificationTimeout(verification.getSendAttempts()); + verification.setNextSendAttemptAt(Instant.now().plusSeconds(secondsUntilNextAttempt)); + } else { + // Create new verification record for registration + verification = new EmailVerification(); + verification.setEmail(email); + verification.setSendAttempts(1); + secondsUntilNextAttempt = getSendVerificationTimeout(verification.getSendAttempts()); + verification.setNextSendAttemptAt(Instant.now().plusSeconds(secondsUntilNextAttempt)); + } + + // Generate verification code and send email + String verificationCode = VerificationCodeGenerator.generateVerificationCode(); + while (emailVerificationRepository.existsByVerificationCode(passwordEncoder.encode(verificationCode))) { + verificationCode = VerificationCodeGenerator.generateVerificationCode(); + } + + Instant codeExpiresAt = Instant.now().plusSeconds(600); // 10-minute expiry + + verification.setVerificationCode(passwordEncoder.encode(verificationCode)); + verification.setCodeExpiresAt(codeExpiresAt); + + String expiryTime = codeExpiresAt.toString(); + // Email is sent asynchronously - errors are logged by the email service + emailService.sendVerificationCodeEmail(email, verificationCode, expiryTime); + logger.info("Email verification code sent for registration to: " + email); + emailVerificationRepository.save(verification); + + return new EmailVerificationResponseDTO(secondsUntilNextAttempt, "Verification code sent successfully"); + } + + @Override + public AuthResponseDTO checkEmailVerificationCode(String email, String code) { + logger.info("Verifying email and creating user for: " + email); + + if (email == null || code == null) { + throw new IllegalArgumentException("Email and code cannot be null"); + } + + // Check if user already exists + if (userService.existsByEmailAndStatus(email, UserStatus.ACTIVE)) { + throw new EmailAlreadyExistsException("Email already exists"); + } + + if (!emailVerificationRepository.existsByEmail(email)) { + throw new IllegalArgumentException("No verification record found for this email"); + } + + // Find verification record + EmailVerification verification = emailVerificationRepository.findByEmail(email); + + if (verification.getNextCheckAttemptAt() != null && verification.getNextCheckAttemptAt().isAfter(Instant.now())) { + throw new TooManyAttemptsException("Wait before checking another email verification code: " + verification.getNextCheckAttemptAt().toString()); + } + + // Check if code has expired + if (verification.getCodeExpiresAt().isBefore(Instant.now())) { + throw new EmailVerificationException("Verification code has expired"); + } + + // Check if code matches + if (!passwordEncoder.matches(code, verification.getVerificationCode())) { + verification.setCheckAttempts(verification.getCheckAttempts() + 1); + long secondsToWait = getCheckVerificationTimeout(verification.getCheckAttempts()); + verification.setNextCheckAttemptAt(Instant.now().plusSeconds(secondsToWait)); + emailVerificationRepository.save(verification); + throw new EmailVerificationException("Incorrect verification code"); + } + + // Code is valid + User user; + // If this is a new user, create an account for them + if (!userService.existsByEmail(email)) { + user = new User(); + user.setId(UUID.randomUUID()); + user.setEmail(email); + user.setUsername(email); + user.setName(email); + user.setPhoneNumber(email); + user.setStatus(UserStatus.EMAIL_VERIFIED); + user = userService.createAndSaveUser(user); + logger.info("User created successfully after email verification: " + LoggingUtils.formatUserInfo(user)); + } else { // Otherwise return their existing account still in onboarding + user = userService.getUserByEmail(email); + logger.info("Re-verified existing user"); + } + + // Clean up verification record + emailVerificationRepository.delete(verification); + + return UserMapper.toAuthResponseDTO(user, false); + } + + @Override + public BaseUserDTO completeContactImport(UUID userId) { + try { + logger.info("Completing contact import for user: " + LoggingUtils.formatUserIdInfo(userId)); + + User user = userService.getUserEntityById(userId); + user.setStatus(UserStatus.CONTACT_IMPORT); + user = userService.saveEntity(user); + + logger.info("Successfully updated user status to CONTACT_IMPORT: " + LoggingUtils.formatUserInfo(user)); + return UserMapper.toDTO(user); + } catch (Exception e) { + logger.error("Error completing contact import for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); + throw e; + } + } + + @Override + public BaseUserDTO acceptTermsOfService(UUID userId) { + try { + logger.info("Accepting Terms of Service for user: " + LoggingUtils.formatUserIdInfo(userId)); + + User user = userService.getUserEntityById(userId); + + // Validate and clean up user data before changing status to ACTIVE + // This prevents constraint violations for OAuth users with placeholder data + validateAndCleanupUserData(user); + + user.setStatus(UserStatus.ACTIVE); + user = userService.saveEntity(user); + + logger.info("Successfully updated user status to ACTIVE: " + LoggingUtils.formatUserInfo(user)); + return UserMapper.toDTO(user); + } catch (Exception e) { + logger.error("Error accepting Terms of Service for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); + throw e; + } + } + + /** + * Validates user data before setting status to ACTIVE + * Uses Optional-based methods for safe null handling + */ + private void validateAndCleanupUserData(User user) { + // Ensure email exists since that's required for ACTIVE users + if (user.getOptionalEmail().isEmpty()) { + throw new IllegalStateException("Cannot activate user without email address"); + } + + // Check if user has required fields for their current status progression + if (!user.hasRequiredFieldsForStatus()) { + logger.warn("User " + LoggingUtils.formatUserIdInfo(user.getId()) + + " is being set to ACTIVE but may be missing required fields for their status progression"); + } + + // Log current state for debugging using Optional methods + logger.info("User " + LoggingUtils.formatUserIdInfo(user.getId()) + + " ready for ACTIVE status - username: " + (user.getOptionalUsername().isPresent() ? "set" : "null") + + ", phoneNumber: " + (user.getOptionalPhoneNumber().isPresent() ? "set" : "null") + + ", name: " + (user.getOptionalName().isPresent() ? "set" : "null") + + ", displayName: '" + user.getDisplayName() + "'"); + } + + private long getSendVerificationTimeout(int numAttempts) { + long secondsToWait; + if (numAttempts <= 5) { + secondsToWait = 30; + } else if (numAttempts <= 10) { + secondsToWait = 120; + } else if (numAttempts <= 15) { + secondsToWait = 600; + } else { + secondsToWait = 3600 * 2; + } + return secondsToWait; + } + + private long getCheckVerificationTimeout(int numAttempts) { + long secondsToWait; + if (numAttempts <= 5) { + secondsToWait = 0; + } else if (numAttempts <= 7) { + secondsToWait = 30; + } else if (numAttempts < 10) { + secondsToWait = 120; + } else { + secondsToWait = 3600 * 2; // 2 hours + } + return secondsToWait; + } + + /* ------------------------------ HELPERS ------------------------------ */ + + private void checkIfUniqueCredentials(AuthUserDTO authUserDTO) { + if (userService.existsByEmail(authUserDTO.getEmail())) { + logger.warn("Registration attempt with existing email: " + authUserDTO.getEmail()); + throw new EmailAlreadyExistsException("Email: " + authUserDTO.getEmail() + " already exists"); + } + if (userService.existsByUsername(authUserDTO.getUsername())) { + logger.warn("Registration attempt with existing username: " + authUserDTO.getUsername()); + throw new UsernameAlreadyExistsException("Username: " + authUserDTO.getUsername() + " already exists"); + } + } + + private UserDTO createAndSaveUser(AuthUserDTO authUserDTO) { + User user = new User(); + + user.setId(UUID.randomUUID()); // can't be null + user.setUsername(authUserDTO.getUsername()); + user.setEmail(authUserDTO.getEmail()); + user.setPassword(passwordEncoder.encode(authUserDTO.getPassword())); + user.setName(authUserDTO.getName()); // Set the name from AuthUserDTO + user.setPhoneNumber(authUserDTO.getUsername()); // Use username as phone number placeholder + user.setDateCreated(new Date()); + + user = userService.createAndSaveUser(user); + return UserMapper.toDTO(user, java.util.List.of()); + } + + private void createEmailTokenAndSendEmail(AuthUserDTO authUserDTO) { + String emailToken = jwtService.generateEmailToken(authUserDTO.getUsername()); + // Email is sent asynchronously - errors are logged by the email service + emailService.sendVerifyAccountEmail(authUserDTO.getEmail(), emailToken); + } + +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/EmailService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/EmailService.java new file mode 100644 index 000000000..f14dc97ec --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/EmailService.java @@ -0,0 +1,120 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.util.EmailTemplates; +import io.github.cdimascio.dotenv.Dotenv; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.AllArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + + +@Service +@AllArgsConstructor +public class EmailService implements IEmailService { + private static final String BASE_URL; + + static { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + BASE_URL = dotenv.get("BASE_URL"); + + } + + // Dependency to send emails which uses the properties specified in application.properties + private final JavaMailSender mailSender; + private final ILogger logger; + + + @Override + @Async("emailTaskExecutor") + public void sendEmail(String to, String subject, String content) { + logger.info("Sending email asynchronously to " + to); + try { + sendMimeEmail(to, subject, content); + logger.info("Email sent successfully to " + to); + } catch (MessagingException e) { + logger.error("Failed to send email to " + to + ": " + e.getMessage()); + // Exception is logged but not re-thrown since this is an async method + } catch (Exception e) { + logger.error("Unexpected error sending email to " + to + ": " + e.getMessage()); + } + } + + @Override + @Async("emailTaskExecutor") + public void sendVerifyAccountEmail(String to, String token) { + logger.info("Sending verification email asynchronously to " + to); + try { + final String link = BASE_URL + token; + final String content = buildVerifyEmailBody(link); + final String subject = "Verify Account"; + + sendMimeEmail(to, subject, content); + logger.info("Verification email sent successfully to " + to); + } catch (MessagingException e) { + logger.error("Failed to send verification email to " + to + ": " + e.getMessage()); + } catch (Exception e) { + logger.error("Unexpected error sending verification email to " + to + ": " + e.getMessage()); + } + } + + @Override + @Async("emailTaskExecutor") + public void sendVerificationCodeEmail(String to, String verificationCode, String expiryTime) { + logger.info("Sending verification code email asynchronously to " + to); + try { + final String content = buildVerificationCodeEmailBody(verificationCode, expiryTime); + final String subject = "Your Verification Code: " + verificationCode; + + sendMimeEmail(to, subject, content); + logger.info("Verification code email sent successfully to " + to); + } catch (MessagingException e) { + logger.error("Failed to send verification code email to " + to + ": " + e.getMessage()); + } catch (Exception e) { + logger.error("Unexpected error sending verification code email to " + to + ": " + e.getMessage()); + } + } + + /** + * Creates and sends a MIME email message with the provided details. + * MIME (Multipurpose Internet Mail Extensions) is an internet standard for email message format. + * + * @param to The recipient email address + * @param subject The email subject line + * @param content The HTML content of the email + * @throws MessagingException if there's an error creating or sending the email + */ + private void sendMimeEmail(String to, String subject, String content) throws MessagingException { + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper mimeHelper = new MimeMessageHelper(message, "utf-8"); + mimeHelper.setTo(to); + mimeHelper.setSubject(subject); + mimeHelper.setFrom(new InternetAddress("Spawn ")); + mimeHelper.setText(content, true); // true enables HTML + mailSender.send(message); + } + + /** + * Gets the "verify email" template and inserts the link + */ + private String buildVerifyEmailBody(String link) { + String verifyEmailBody = EmailTemplates.getVerifyEmailBody(); + return verifyEmailBody.replace("[VERIFICATION_LINK]", link); + } + + /** + * Gets the "verification code" template and inserts the code and expiry time + */ + private String buildVerificationCodeEmailBody(String verificationCode, String expiryTime) { + String verificationCodeBody = EmailTemplates.getEmailVerificationCodeBody(); + return verificationCodeBody + .replace("[VERIFICATION_CODE]", verificationCode) + .replace("[EXPIRY_TIME]", expiryTime); + } + +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/GoogleOAuthStrategy.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/GoogleOAuthStrategy.java new file mode 100644 index 000000000..cf7af4a4c --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/GoogleOAuthStrategy.java @@ -0,0 +1,142 @@ +package com.danielagapov.spawn.auth.internal.services; + + +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.exceptions.TokenExpiredException; +import com.danielagapov.spawn.shared.exceptions.OAuthProviderUnavailableException; +import com.danielagapov.spawn.shared.util.RetryHelper; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.Collections; + +@Service +public final class GoogleOAuthStrategy implements OAuthStrategy { + private final ILogger logger; + private GoogleIdTokenVerifier verifier; + + @Value("${google.client.id}") + private String googleClientId; + + + @Autowired + public GoogleOAuthStrategy(ILogger logger) { + this.logger = logger; + // Don't initialize verifier here - will be initialized in @PostConstruct with proper client ID + } + + @Override + public OAuthProvider getOAuthProvider() { + return OAuthProvider.google; + } + + /** + * Verifies a Google ID token and extracts the subject (user ID) + * + * @param idToken Google ID token to verify + * @return the subject (user ID) extracted from the token + */ + @Override + public String verifyIdToken(String idToken) { + try { + logger.info("Attempting to verify Google ID token"); + logger.info("Using client ID: " + googleClientId); + + // Use retry helper for token verification + return RetryHelper.executeOAuthWithRetry(() -> { + try { + GoogleIdToken googleIdToken = null; + // Verify the token + try { + googleIdToken = verifier.verify(idToken); + } catch (Error e) { + logger.error(e.getMessage()); + } + + if (googleIdToken == null) { + logger.error("Token verification failed - invalid token. Token prefix: " + (idToken != null ? idToken.substring(0, Math.min(20, idToken.length())) + "..." : "null")); + throw new SecurityException("Invalid Google ID token - token may be expired or malformed"); + } + + logger.info("Token verified successfully"); + // Get payload data + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + String userId = payload.getSubject(); // Get the user's ID + logger.info("Extracted user ID: " + userId); + + // Check token expiration + Long expiration = payload.getExpirationTimeSeconds(); + if (expiration != null && expiration < System.currentTimeMillis() / 1000) { + logger.error("Token has expired. Expiration: " + expiration + ", Current time: " + (System.currentTimeMillis() / 1000)); + throw new TokenExpiredException("Google ID token has expired, please sign in again"); + } + + // Verify additional claims if needed + // For example, verify email is verified + Boolean emailVerified = payload.getEmailVerified(); + if (emailVerified == null || !emailVerified) { + logger.error("Email not verified for user ID: " + userId + ", emailVerified value: " + emailVerified); + throw new SecurityException("Google account email is not verified"); + } + + return userId; + + } catch (GeneralSecurityException e) { + logger.error("Security error during token verification: " + e.getMessage()); + throw new SecurityException("Security error during Google token verification: " + e.getMessage(), e); + } catch (IOException e) { + logger.error("Network error during token verification: " + e.getMessage()); + throw new OAuthProviderUnavailableException("Google authentication service is temporarily unavailable. Please try again later.", e); + } + }); + + } catch (TokenExpiredException e) { + logger.error("Token expired: " + e.getMessage()); + throw e; + } catch (OAuthProviderUnavailableException e) { + logger.error("OAuth provider unavailable: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error during token verification: " + e.getMessage()); + logger.error("Token details: " + (idToken != null ? idToken.substring(0, Math.min(20, idToken.length())) + "..." : "null")); + throw new SecurityException("Unexpected error during Google token verification: " + e.getMessage(), e); + } + } + + // Updated method with @PostConstruct to ensure client ID is loaded from properties + @PostConstruct + public void initializeGoogleVerifier() { + // Try to get client ID from property, which should come from env variable + String clientId = googleClientId; + logger.info("Retrieved Google client ID from application properties: " + (clientId != null ? (clientId.substring(0, Math.min(10, clientId.length())) + "...") : "null")); + + // If not set in property, try to get directly from environment + if (clientId == null || clientId.isEmpty()) { + clientId = System.getenv("GOOGLE_CLIENT_ID"); + logger.info("Getting Google client ID directly from environment variable: " + (clientId != null ? (clientId.substring(0, Math.min(10, clientId.length())) + "...") : "null")); + } + + // Re-initialize Google ID token verifier with client ID from properties or environment + if (clientId != null && !clientId.isEmpty()) { + logger.info("Initializing Google token verifier with client ID: " + clientId); + this.verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(clientId)) + .build(); + logger.info("Google token verifier successfully initialized"); + } else { + logger.error("Google client ID not set, token verification will fail. Set GOOGLE_CLIENT_ID in your environment. clientId value: " + (clientId == null ? "null" : "empty string")); + // Create a dummy verifier that will reject all tokens + this.verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()).build(); + logger.warn("Created dummy verifier that will reject all tokens - Google OAuth will not work"); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IAuthService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IAuthService.java new file mode 100644 index 000000000..0cd2f8555 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IAuthService.java @@ -0,0 +1,94 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.danielagapov.spawn.auth.api.dto.EmailVerificationResponseDTO; +import com.danielagapov.spawn.auth.api.dto.OAuthRegistrationDTO; +import com.danielagapov.spawn.user.api.dto.*; +import com.danielagapov.spawn.user.internal.domain.User; +import org.springframework.http.HttpHeaders; + +import java.util.UUID; + +public interface IAuthService { + /** + * Registers the user by creating + */ + UserDTO registerUser(AuthUserDTO authUserDTO); + + AuthResponseDTO loginUser(String usernameOrEmail, String password); + + boolean verifyEmail(String token); + + /** + * Changes a user's password after verifying the current password + * @param username the username of the user + * @param currentPassword the current password + * @param newPassword the new password + * @return true if password change was successful + */ + boolean changePassword(String username, String currentPassword, String newPassword); + + AuthResponseDTO getUserByToken(String token); + + /** + * Sends an email verification code to the specified email address for new user registration + * @param email the email address to send the verification code to + * @return response containing seconds until next attempt and message + */ + EmailVerificationResponseDTO sendEmailVerificationCodeForRegistration(String email); + + /** + * Checks the email verification code and creates a new user upon successful verification + * @param email the email address that received the verification code + * @param code the verification code to check + * @return the created user DTO if verification is successful + */ + AuthResponseDTO checkEmailVerificationCode(String email, String code); + + /** + * Registers a new user via OAuth (Google or Apple) + * + * @return the created user DTO with status for onboarding navigation + */ + AuthResponseDTO registerUserViaOAuth(OAuthRegistrationDTO registrationDTO); + + /** + * Handles OAuth registration gracefully by converting exceptions to appropriate user states + * @param registrationDTO the OAuth registration data + * @param exception the exception that occurred during registration + * @return a graceful AuthResponseDTO that guides the user to the appropriate next step + */ + AuthResponseDTO handleOAuthRegistrationGracefully(OAuthRegistrationDTO registrationDTO, Exception exception); + + /** + * Helper method to call access/refresh token-generating methods and place them in the appropriate + * HTTP headers + */ + HttpHeaders makeHeadersForTokens(String username); + + /** + * Helper method to generate tokens for users, using email as fallback when username is null + * This is specifically needed for OAuth users during onboarding who don't have usernames yet + */ + HttpHeaders makeHeadersForTokens(User user); + + /** + * Updates user details (username, phone number, password) for an existing user + * @param dto the update details DTO + * @return the updated user DTO + */ + BaseUserDTO updateUserDetails(UpdateUserDetailsDTO dto); + + /** + * Mark contact import step as completed + * @param userId the ID of the user who completed contact import + * @return the updated user DTO + */ + BaseUserDTO completeContactImport(UUID userId); + + /** + * Update user status to ACTIVE (for Terms of Service acceptance) + * @param userId the ID of the user to update + * @return the updated user DTO + */ + BaseUserDTO acceptTermsOfService(UUID userId); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IEmailService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IEmailService.java new file mode 100644 index 000000000..a29ad987c --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IEmailService.java @@ -0,0 +1,27 @@ +package com.danielagapov.spawn.auth.internal.services; + +public interface IEmailService { + + /** + * Base method used to send emails with given recipient (to), subject, and content. + * Only public usage is by the test-email endpoint in AuthController. + * This method executes asynchronously and handles exceptions internally. + */ + void sendEmail(String to, String subject, String content); + + /** + * Builds and sends an email to a new user with a link to verify their account. + * Builds the verification link from the given token. + * This method executes asynchronously and handles exceptions internally. + */ + void sendVerifyAccountEmail(String to, String token); + + /** + * Builds and sends an email with a verification code to verify a user's email address. + * This method executes asynchronously and handles exceptions internally. + * @param to the email address to send the verification code to + * @param verificationCode the 6-digit verification code + * @param expiryTime the time when the code expires + */ + void sendVerificationCodeEmail(String to, String verificationCode, String expiryTime); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IJWTService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IJWTService.java new file mode 100644 index 000000000..4c6a4f1d0 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IJWTService.java @@ -0,0 +1,41 @@ +package com.danielagapov.spawn.auth.internal.services; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.core.userdetails.UserDetails; + +public interface IJWTService { + /** + * A JWT has the following structure: + * - Header: contains info about token type and signing algorithm + * - Payload: contains data (or claims) about the user and token such as subject and issued and expiration time + * - Signature: composed of the header, payload, and a secret that was specified at token creation, and is used to + * verify the integrity of the token + */ + + /** + * Extracts the username (from 'subject' claim) from the 'payload' of the JWT. This username is used for setting authentication + * for incoming requests. + */ + String extractUsername(String token); + + /** + * Determines whether the JWT is valid by checking for expiry, + */ + boolean isValidToken(String token, UserDetails userDetails); + + /** + * Generates a JWT with the given username as the subject claim + */ + String generateAccessToken(String username); + + String refreshAccessToken(HttpServletRequest request); + + String generateRefreshToken(String username); + + /** + * Generates a JWT for email verification + */ + String generateEmailToken(String username); + + boolean isValidEmailToken(String token); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IOAuthService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IOAuthService.java new file mode 100644 index 000000000..ef541fe54 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/IOAuthService.java @@ -0,0 +1,84 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.danielagapov.spawn.user.api.dto.AuthResponseDTO; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserCreationDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.Optional; +import java.util.UUID; + +/** + * Service interface for managing OAuth authentication operations. + * Handles user authentication, account creation, and mapping between external OAuth providers and internal user accounts. + */ +public interface IOAuthService { + + /** + * Given a new user dto, creates a new account which means saving the user info and their external id mapping + * + * @param user new user to save + * @param externalUserId user id from external provider + * @param profilePicture byte arr of user's pfp + * @param provider oauth provider that the new user used to sign in with + * @return BaseUserDTO of the newly created user + * @throws org.springframework.dao.DataAccessException if database operations fail + */ + BaseUserDTO makeUser(UserDTO user, String externalUserId, byte[] profilePicture, OAuthProvider provider); + + /** + * Signs in a user using their OAuth ID token and email address. + * Verifies the token, extracts the user ID, and checks if the user exists. + * + * @param idToken the OAuth ID token to verify + * @param email the user's email address + * @param provider the OAuth provider used for authentication + * @return Optional containing AuthResponseDTO if user exists, empty otherwise + * @throws com.danielagapov.spawn.Exceptions.IncorrectProviderException if user exists but with different provider + * @throws SecurityException if token verification fails + */ + Optional signInUser(String idToken, String email, OAuthProvider provider); + + /** + * Given an external user id from an oauth provider, check whether it belongs it a user account. + * First tries to find user by external id, then by email in case a user has signed in with a different provider + * + * @param externalUserId user id from external provider + * @param email user email + * @return a BaseUserDTO if user exists, null otherwise + * @throws com.danielagapov.spawn.Exceptions.IncorrectProviderException if user exists but with different provider + */ + Optional getUserIfExistsbyExternalId(String externalUserId, String email); + + /** + * Creates a user from either Google ID token or Apple external user ID + * + * @param userCreationDTO DTO containing user creation details + * @param idToken ID token + * @param provider OAuth provider (required for Apple) + * @return Created or existing user BaseUserDTO + * @throws IllegalArgumentException when required parameters are missing + * @throws SecurityException when token validation fails + */ + BaseUserDTO createUserFromOAuth(UserCreationDTO userCreationDTO, String idToken, OAuthProvider provider); + + String checkOAuthRegistration(String email, String idToken, OAuthProvider provider); + + void createAndSaveMapping(User user, String externalId, OAuthProvider provider); + + /** + * Performs comprehensive cleanup of orphaned OAuth data that can occur during concurrent operations. + * This method should be called when data inconsistencies are detected during OAuth flows. + * + * @param email The email to check for orphaned data + * @param externalUserId The external user ID to check for orphaned mappings + * @return true if cleanup was performed, false if no cleanup was needed + */ + boolean performDataConsistencyCleanup(String email, String externalUserId); + + boolean isOAuthUser(UUID userId); + + OAuthProvider getOAuthProvider(UUID userId); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/JWTService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/JWTService.java new file mode 100644 index 000000000..afaa34894 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/JWTService.java @@ -0,0 +1,331 @@ +package com.danielagapov.spawn.auth.internal.services; + +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.exceptions.Token.BadTokenException; +import com.danielagapov.spawn.shared.exceptions.Token.TokenNotFoundException; +import com.danielagapov.spawn.user.internal.services.IUserService; +import io.github.cdimascio.dotenv.Dotenv; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.io.Decoders; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.function.Function; +import java.util.Set; + +@Service +// TODO: consider refactor to type hierarchy with AccessToken, RefreshToken, EmailToken extending JWTService +public class JWTService implements IJWTService { + private final String signingSecret; + + private enum TokenType {ACCESS, REFRESH, EMAIL} + + private static final long ACCESS_TOKEN_EXPIRY = 1000L * 60 * 60 * 24; // 24 hours + private static final long REFRESH_TOKEN_EXPIRY = 1000L * 60 * 60 * 24 * 60; // 60 days (reduced from 180 days for security) + private static final long EMAIL_TOKEN_EXPIRY = 1000L * 60 * 60 * 24; // 24 hours + private final ILogger logger; + private final IUserService userService; + + public JWTService( + ILogger logger, + IUserService userService, + @Value("${jwt.signing-secret:#{null}}") String configuredSecret + ) { + this.logger = logger; + this.userService = userService; + + // Priority: 1) Spring property, 2) Environment variable, 3) .env file + if (configuredSecret != null && !configuredSecret.isEmpty()) { + this.signingSecret = configuredSecret; + } else { + String envSecret = System.getenv("SIGNING_SECRET"); + if (envSecret != null && !envSecret.isEmpty()) { + this.signingSecret = envSecret; + } else { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + this.signingSecret = dotenv.get("SIGNING_SECRET"); + } + } + } + + + @Override + public String extractUsername(String token) { + try { + return extractClaim(token, Claims::getSubject); + } catch (ExpiredJwtException e) { + logger.warn("Token has expired: " + e.getMessage()); + throw e; + } catch (SignatureException e) { + logger.warn("Invalid token signature: " + e.getMessage()); + throw e; + } catch (JwtException e) { + logger.warn("JWT parsing error: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error extracting username: " + e.getMessage()); + throw e; + } + } + + @Override + public boolean isValidToken(String token, UserDetails userDetails) { + try { + final String username = extractUsername(token); + + // Validate basic token properties + boolean isUsernameValid = username.equals(userDetails.getUsername()); + boolean isTokenNonExpired = isTokenNonExpired(token); + boolean isCorrectType = isMatchingTokenType(token, TokenType.ACCESS); + + // Additional security validations + boolean hasValidIssuer = isValidIssuer(token); + boolean hasValidAudience = isValidAudience(token); + + return isUsernameValid && isTokenNonExpired && isCorrectType && hasValidIssuer && hasValidAudience; + } catch (Exception e) { + logger.warn("Token validation failed: " + e.getMessage()); + return false; + } + } + + + @Override + public String generateAccessToken(String username) { + logger.info("Generating access token for user: " + username); + Map claims = makeClaims(TokenType.ACCESS); + return generateToken(username, ACCESS_TOKEN_EXPIRY, claims); + } + + @Override + public String refreshAccessToken(HttpServletRequest request) { + final String authHeader = request.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + throw new TokenNotFoundException("No refresh token found"); + } + // Extract the JWT token from the Authorization header (removing the "Bearer " prefix) + final String token = authHeader.substring(7); + final String subject; + try { + subject = extractUsername(token); // This actually extracts the subject, which could be username or email + } catch (Exception e) { + logger.error("Failed to extract subject. Invalid or expired token: " + e.getMessage()); + throw e; + } + + if (subject == null) { + logger.warn("Token subject is null. Token prefix: " + (token != null ? token.substring(0, Math.min(20, token.length())) + "..." : "null")); + throw new BadTokenException(); + } + + // Check if subject corresponds to a user - first try as username, then as email + boolean userExists = false; + String usernameForNewToken = null; + + if (userService.existsByUsername(subject)) { + // Subject is a username + userExists = true; + usernameForNewToken = subject; + } else if (userService.existsByEmail(subject)) { + // Subject is an email (for OAuth users with null usernames) + userExists = true; + // For OAuth users, we need to determine what to use for the new token + try { + var user = userService.getUserByEmail(subject); + // Use username if available, otherwise use email + usernameForNewToken = user.getOptionalUsername().orElse(user.getEmail()); + } catch (Exception e) { + logger.error("Failed to get user by email: " + subject + ": " + e.getMessage()); + throw new BadTokenException(); + } + } + + if (!userExists) { + logger.warn("Subject does not correspond to any user entity: " + subject); + throw new BadTokenException(); + } + + if (isTokenNonExpired(token) && isMatchingTokenType(token, TokenType.REFRESH)) { + // This is a valid refresh token, grant a new access token to the requester + String newAccessToken = generateAccessToken(usernameForNewToken); + return newAccessToken; + } else { + logger.warn("Expired or invalid token type found for subject: " + subject); + throw new BadTokenException(); + } + } + + @Override + public String generateRefreshToken(String username) { + logger.info("Generating refresh token for user: " + username); + Map claims = makeClaims(TokenType.REFRESH); + return generateToken(username, REFRESH_TOKEN_EXPIRY, claims); + } + + @Override + public String generateEmailToken(String username) { + logger.info("Generating email token for user: " + username); + Map claims = makeClaims(TokenType.EMAIL); + return generateToken(username, EMAIL_TOKEN_EXPIRY, claims); + } + + @Override + public boolean isValidEmailToken(String token) { + try { + return isTokenNonExpired(token) && isMatchingTokenType(token, TokenType.EMAIL); + } catch (Exception e) { + logger.warn("Email token validation failed: " + e.getMessage()); + return false; + } + } + + + /* ------------------------------ HELPERS ------------------------------ */ + + private String generateToken(String username, long expiry, Map claims) { + try { + if (signingSecret == null || signingSecret.trim().isEmpty()) { + throw new SecurityException("JWT signing secret is not configured"); + } + + return Jwts.builder() + .header() + .type("JWT") + .add("alg", "HS256") // Explicitly specify algorithm to prevent algorithm confusion attacks + .and() + .claims() + .add(claims) + .subject(username) + .issuer("spawn-backend") // Add issuer for additional validation + .audience().add("spawn-app").and() // Add audience validation + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiry)) + .and() + .signWith(getKey(), Jwts.SIG.HS256) // Explicitly specify algorithm + .compact(); + } catch (Exception e) { + logger.error("Error generating JWT token: " + e.getMessage()); + throw e; + } + } + + /** + * Helper method used to extract a particular claim from the payload of a JWT. + * The parameter claimsResolver, is some helper method from a JWT dependency to extract the claim with correct type + */ + private T extractClaim(String token, Function claimsResolver) { + Claims claims = extractAllClaims(token); + return claimsResolver.apply(claims); + } + + /** + * Extracts the entire payload (i.e. all claims) from the JWT which involves parsing the token + */ + private Claims extractAllClaims(String token) { + try { + return Jwts.parser() + .verifyWith(getKey()) + .build() + .parseSignedClaims(token) + .getPayload(); + } catch (JwtException e) { + logger.warn("JWT parsing error in extractAllClaims: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error in extractAllClaims: " + e.getMessage()); + throw e; + } + } + + private TokenType extractTokenType(String token) { + try { + Claims claims = extractAllClaims(token); + String typeAsString = (String) claims.get("type"); + return TokenType.valueOf(typeAsString); // returns "type" claim as TokenType + } catch (IllegalArgumentException e) { + logger.warn("Invalid token type value: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.warn("Error extracting token type: " + e.getMessage()); + throw e; + } + } + + /** + * Returns whether the token is expired + */ + private boolean isTokenNonExpired(String token) { + try { + return !extractClaim(token, Claims::getExpiration).before(new Date()); + } catch (ExpiredJwtException e) { + logger.warn("Token has expired: " + e.getMessage()); + return false; + } catch (Exception e) { + logger.warn("Error checking token expiration: " + e.getMessage()); + throw e; + } + } + + /** + * This method generates the signing key for a JWT by converting the base64 encoded secret string field + * into a cryptographic key using HMAC-SHA + */ + private SecretKey getKey() { + try { + final byte[] keyBytes = Decoders.BASE64.decode(signingSecret); + return Keys.hmacShaKeyFor(keyBytes); + } catch (Exception e) { + logger.error("Error generating signing key: " + e.getMessage()); + throw e; + } + } + + private Map makeClaims(TokenType type) { + final Map claims = new HashMap<>(); + claims.put("type", type); + return claims; + } + + private boolean isMatchingTokenType(String token, TokenType tokenType) { + try { + final TokenType type = extractTokenType(token); + return type == tokenType; + } catch (Exception e) { + logger.warn("Error matching token type: " + e.getMessage()); + return false; + } + } + + private boolean isValidIssuer(String token) { + try { + Claims claims = extractAllClaims(token); + String issuer = claims.getIssuer(); + return "spawn-backend".equals(issuer); + } catch (Exception e) { + logger.warn("Error validating token issuer: " + e.getMessage()); + return false; + } + } + + private boolean isValidAudience(String token) { + try { + Claims claims = extractAllClaims(token); + Set audiences = claims.getAudience(); + return audiences != null && audiences.contains("spawn-app"); + } catch (Exception e) { + logger.warn("Error validating token audience: " + e.getMessage()); + return false; + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthService.java new file mode 100644 index 000000000..8707b1605 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthService.java @@ -0,0 +1,655 @@ +package com.danielagapov.spawn.auth.internal.services; + + +import com.danielagapov.spawn.user.api.dto.AuthResponseDTO; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserCreationDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.util.OAuthProvider; +import com.danielagapov.spawn.shared.util.UserStatus; +import com.danielagapov.spawn.shared.exceptions.AccountAlreadyExistsException; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.IncorrectProviderException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.util.UserMapper; +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.auth.internal.domain.UserIdExternalIdMap; +import com.danielagapov.spawn.auth.internal.repositories.IUserIdExternalIdMapRepository; +import com.danielagapov.spawn.user.internal.services.IUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + + +@Service +public class OAuthService implements IOAuthService { + private final IUserService userService; + private final IUserIdExternalIdMapRepository externalIdMapRepository; + private final Map oauthProviders; + private final ILogger logger; + + // Application-level synchronization for OAuth operations per external ID + private final ConcurrentHashMap externalIdLocks = new ConcurrentHashMap<>(); + + @Autowired + public OAuthService(IUserIdExternalIdMapRepository externalIdMapRepository, + IUserService userService, + ILogger logger, + List providers) { + this.externalIdMapRepository = externalIdMapRepository; + this.userService = userService; + this.logger = logger; + this.oauthProviders = providers.stream() + .collect(Collectors.toMap(OAuthStrategy::getOAuthProvider, strategy -> strategy)); + } + + @Override + public BaseUserDTO makeUser(UserDTO user, String externalUserId, byte[] profilePicture, OAuthProvider provider) { + try { + logger.info(String.format("Making user: {user: %s, externalUserId: %s}", user, externalUserId)); + + // Check if this external user already exists + boolean existsByExternalId = mappingExistsByExternalId(externalUserId); + + // Check if a user exists with this email + boolean existsByEmail = userService.existsByEmail(user.getEmail()); + + // Case 1: There's already a mapping for this externalId + if (existsByExternalId) { + logger.info("Existing user detected in makeUser, mapping already exists"); + User existingUser = getMapping(externalUserId).getUser(); + + // Only delete users that have non-active statuses (null is treated as active for backward compatibility) + if (existingUser.getStatus() != null && existingUser.getStatus() != UserStatus.ACTIVE) { + logger.info("Found incomplete user account (status: " + existingUser.getStatus() + "). Allowing re-creation."); + // Delete the incomplete user and their mapping to allow fresh creation + userService.deleteUserById(existingUser.getId()); + // The mapping will be explicitly deleted with the user + } else { + logger.info("Returning existing active user"); + return UserMapper.toDTO(existingUser); + } + } + + // Case 2: There's already a Spawn user with this email address, but no mapping with this external id + // In this case, the user signed in with a different provider initially, so we should not allow creation + // with this provider + if (existsByEmail) { + logger.info("Existing user detected in makeUser, email already exists"); + try { + UserIdExternalIdMap externalIdMap = getMappingByUserEmail(user.getEmail()); + User existingUser = externalIdMap.getUser(); + + // Only delete users that have non-active statuses (null is treated as active for backward compatibility) + if (existingUser.getStatus() != null && existingUser.getStatus() != UserStatus.ACTIVE) { + logger.info("Found incomplete user account with email (status: " + existingUser.getStatus() + "). Allowing re-creation."); + // Delete the incomplete user and their mapping to allow fresh creation + userService.deleteUserById(existingUser.getId()); + // The mapping will be explicitly deleted with the user + } else { + logger.info("Returning existing active user with different provider"); + return UserMapper.toDTO(existingUser); + } + } catch (BaseNotFoundException e) { + logger.warn("User email exists but no mapping found - this may be due to data inconsistency. Attempting graceful repair in makeUser: " + e.getMessage()); + + // Attempt to repair the data inconsistency gracefully + try { + User orphanedUser = userService.getUserByEmail(user.getEmail()); + logger.info("Found orphaned user for email: " + user.getEmail() + ", user ID: " + orphanedUser.getId()); + + // For users with reasonable data, attempt to create a mapping instead of deleting + if (orphanedUser.getStatus() != null && + (orphanedUser.getStatus() == UserStatus.ACTIVE || + orphanedUser.getStatus() == UserStatus.USERNAME_AND_PHONE_NUMBER || + orphanedUser.getStatus() == UserStatus.NAME_AND_PHOTO || + orphanedUser.getStatus() == UserStatus.CONTACT_IMPORT)) { + + // This appears to be a legitimate user - attempt to create missing OAuth mapping + logger.info("Attempting to create missing OAuth mapping for legitimate user in makeUser: " + orphanedUser.getId()); + + try { + createAndSaveMapping(orphanedUser, externalUserId, provider); + logger.info("Successfully created missing OAuth mapping in makeUser for user: " + orphanedUser.getId()); + + // Return the repaired user + logger.info("Returning repaired existing user with different provider"); + return UserMapper.toDTO(orphanedUser); + + } catch (Exception mappingException) { + logger.warn("Failed to create OAuth mapping in makeUser for orphaned user: " + mappingException.getMessage()); + // Fall through to cleanup logic below + } + } + + // If we couldn't repair the mapping or user has incomplete data, delete the orphaned user + logger.info("Cleaning up orphaned user to allow new user creation: " + orphanedUser.getId() + " with email: " + orphanedUser.getEmail()); + userService.deleteUserById(orphanedUser.getId()); + logger.info("Orphaned user deleted: " + orphanedUser.getId() + " with email: " + orphanedUser.getEmail()); + + } catch (Exception repairException) { + logger.error("Failed to repair or delete orphaned user: " + repairException.getMessage()); + } + // Continue to Case 3 - treat as new user + } + } + + // Case 3: This is a new user, neither the externalId nor the email exists in our database + // OR we deleted an incomplete user above + // Save the user with profile picture + UserDTO userDTO = userService.createAndSaveUserWithProfilePicture(user, profilePicture); + + // Get the User entity to create the mapping + User userEntity = userService.getUserEntityById(userDTO.getId()); + + // Save the mapping for the new user to the external id + logger.info(String.format("External user detected, saving mapping: {externalUserId: %s, userDTO: %s}", externalUserId, userDTO)); + createAndSaveMapping(userEntity, externalUserId, provider); + + BaseUserDTO baseUserDTO = UserMapper.toBaseDTO(userDTO); + logger.info(String.format("Returning BaseUserDTO of newly made user: {baseUserDTO: %s}", baseUserDTO)); + return baseUserDTO; + } catch (DataAccessException e) { + logger.error("Database error while creating user: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error while creating user: " + e.getMessage()); + throw e; + } + } + + @Override + public Optional signInUser(String idToken, String email, OAuthProvider provider) { + logger.info("Checking if user signing in with " + provider + " exists by ID token and email: " + email); + OAuthStrategy oauthStrategy = oauthProviders.get(provider); + + // Verify the token and extract the user ID + String userId = oauthStrategy.verifyIdToken(idToken); + logger.info("Successfully verified " + provider + " ID token and extracted user ID: " + userId); + + // Use the extracted user ID to check if the user exists + logger.info("Checking if user exists with " + provider + " user ID: " + userId); + return getUserIfExistsbyExternalId(userId, email); + } + + @Override + public Optional getUserIfExistsbyExternalId(String externalUserId, String email) { + logger.info("Checking if user exists by external ID: " + externalUserId + " and email: " + email); + boolean existsByExternalId = mappingExistsByExternalId(externalUserId); + boolean existsByEmail = email != null && userService.existsByEmail(email); + logger.info("User exists by externalId: " + existsByExternalId + ", exists by email: " + existsByEmail); + + if (existsByExternalId) { // A Spawn account exists with this external id + logger.info("Found existing user by external ID: " + externalUserId); + User user = getMapping(externalUserId).getUser(); + + // Return user regardless of status - client will handle appropriate onboarding + AuthResponseDTO authResponseDTO = UserMapper.toAuthResponseDTO(user, true); + logger.info("Returning user with ID: " + authResponseDTO.getUser().getId() + ", username: " + authResponseDTO.getUser().getUsername() + ", status: " + user.getStatus()); + return Optional.of(authResponseDTO); + } else if (existsByEmail) { // A Spawn account exists with this email but not with the external id + logger.info("Found existing user by email but not by external ID."); + try { + UserIdExternalIdMap externalIdMap = getMappingByUserEmail(email); + User user = externalIdMap.getUser(); + + // For incomplete users, allow them to continue with any provider + if (user.getStatus() != null && user.getStatus() != UserStatus.ACTIVE) { + logger.info("Found user by email but account is not active (status: " + user.getStatus() + "). Returning user for onboarding completion."); + AuthResponseDTO authResponseDTO = UserMapper.toAuthResponseDTO(user); + return Optional.of(authResponseDTO); + } else { + // For active users, enforce provider consistency + OAuthProvider existingProvider = externalIdMap.getProvider(); + String providerName = existingProvider == OAuthProvider.google ? "Google" : "Apple"; + logger.info("Expected provider for this email: " + providerName); + throw new IncorrectProviderException("The email: " + email + " is already associated to a " + providerName + " account. Please login through " + providerName + " instead"); + } + } catch (BaseNotFoundException e) { + logger.warn("User email exists but no mapping found - checking for data inconsistency and attempting cleanup: " + e.getMessage()); + + // Get the user by email to check their status + try { + User orphanedUser = userService.getUserByEmail(email); + + // If user has non-active status (likely EMAIL_VERIFIED), they were likely orphaned during a previous OAuth flow + if (orphanedUser.getStatus() != null && orphanedUser.getStatus() != UserStatus.ACTIVE) { + logger.info("Found orphaned user with status: " + orphanedUser.getStatus() + ". Cleaning up for re-registration."); + + // Clean up the orphaned user to allow fresh registration + userService.deleteUserById(orphanedUser.getId()); + logger.info("Orphaned user deleted. Treating as no user found to allow fresh registration."); + return Optional.empty(); + } else { + logger.warn("Active user exists without OAuth mapping - possible data corruption. Manual intervention may be required. Email: " + email + ", User ID: " + orphanedUser.getId()); + return Optional.empty(); + } + } catch (Exception cleanupEx) { + logger.error("Error during orphaned user cleanup: " + cleanupEx.getMessage()); + // Fallback: treat as no user found to allow registration to proceed + logger.info("Fallback: treating as no user found due to cleanup error."); + return Optional.empty(); + } + } + } else { // No account exists for this external id or email + logger.info("No existing user found for external ID: " + externalUserId + " or email: " + email); + return Optional.empty(); + } + } + + @Override + public BaseUserDTO createUserFromOAuth(UserCreationDTO userCreationDTO, String idToken, OAuthProvider provider) { + try { + logger.info(String.format("Creating user from OAuth: {username: %s, email: %s, provider: %s}", + userCreationDTO.getUsername(), userCreationDTO.getEmail(), provider)); + + // Get the appropriate OAuth strategy + OAuthStrategy oauthStrategy = oauthProviders.get(provider); + if (idToken != null) { + // Verify the token and extract the user ID + String userId = oauthStrategy.verifyIdToken(idToken); + logger.info("Successfully verified " + provider + " ID token and extracted user ID: " + userId); + + UserDTO newUser = UserMapper.toDTOFromCreationUserDTO(userCreationDTO); + + logger.info("Making new user: " + newUser.getUsername()); + return makeUser(newUser, userId, userCreationDTO.getProfilePictureData(), provider); + } else { + logger.error("Missing required authentication parameters. idToken is null: " + (idToken == null) + ", provider: " + provider); + throw new IllegalArgumentException("Either a valid ID token or external user ID with provider must be provided"); + } + } catch (SecurityException e) { + logger.error("Security error during OAuth authentication: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error during OAuth user creation: " + e.getMessage()); + throw e; + } + } + + /** + * Verifies the OAuth registration details provided by checking the ID token + * and determining if the user already exists in the system or is eligible for registration. + * Uses application-level synchronization to prevent race conditions. + * + * @param email the email address provided by the user attempting to register + * @param idToken the ID token obtained through the OAuth provider for authentication + * @param provider the OAuthProvider used for the authentication (e.g., GOOGLE, FACEBOOK) + * @return externalUserId if the user can be registered + * @throws AccountAlreadyExistsException if the external user ID already exists in the system + * @throws IncorrectProviderException if the email is already associated with a different provider + * @throws IllegalArgumentException if required authentication parameters are missing + * @throws SecurityException if there is a security-related issue during OAuth verification + */ + @Override + @Transactional + public String checkOAuthRegistration(String email, String idToken, OAuthProvider provider) { + try { + // Get the appropriate OAuth strategy and verify token first + OAuthStrategy oauthStrategy = oauthProviders.get(provider); + if (idToken == null) { + throw new IllegalArgumentException("ID token must be provided"); + } + + String externalUserId = oauthStrategy.verifyIdToken(idToken); + logger.info("Successfully verified " + provider + " ID token and extracted user ID: " + externalUserId); + + // Use application-level synchronization per external ID to prevent race conditions + Object lock = externalIdLocks.computeIfAbsent(externalUserId, k -> new Object()); + + synchronized (lock) { + try { + return checkOAuthRegistrationWithLock(email, externalUserId, provider); + } finally { + // Clean up the lock if no other threads are waiting + externalIdLocks.remove(externalUserId, lock); + } + } + + } catch (SecurityException e) { + logger.error("Security error during OAuth authentication: " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error during OAuth user creation: " + e.getMessage()); + throw e; + } + } + + /** + * Internal method that handles OAuth registration logic with proper synchronization. + * This method runs within a synchronized block to prevent race conditions. + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + private String checkOAuthRegistrationWithLock(String email, String externalUserId, OAuthProvider provider) { + // Perform all checks atomically within the synchronized block + UserIdExternalIdMap existingMapping = null; + User existingUserByEmail = null; + + try { + existingMapping = externalIdMapRepository.findById(externalUserId).orElse(null); + existingUserByEmail = userService.existsByEmail(email) ? userService.getUserByEmail(email) : null; + } catch (Exception e) { + logger.warn("Error during initial checks: " + e.getMessage()); + } + + // Case 1: There's already a mapping for this externalId + if (existingMapping != null) { + logger.info("Existing user detected in checkOAuthRegistration, mapping already exists"); + User existingUser = existingMapping.getUser(); + + // Only delete users that have non-active statuses (null is treated as active for backward compatibility) + if (existingUser.getStatus() != null && existingUser.getStatus() != UserStatus.ACTIVE) { + logger.info("Found incomplete user account (status: " + existingUser.getStatus() + "). Allowing re-registration."); + // Delete the incomplete user (cascade will handle the mapping) + try { + userService.deleteUserById(existingUser.getId()); + logger.info("Successfully deleted incomplete user, proceeding with fresh registration"); + return externalUserId; // Return immediately to allow fresh registration + } catch (Exception e) { + logger.warn("Error deleting incomplete user: " + e.getMessage()); + // The user might have been deleted by another transaction + // Check again if the mapping still exists + if (!externalIdMapRepository.existsById(externalUserId)) { + logger.info("Mapping was deleted by another transaction, proceeding with registration"); + return externalUserId; + } + // If mapping still exists, fall through to return external ID + } + } + + // For ACTIVE users or if deletion failed, return the external ID + // so the registration flow can handle it appropriately + logger.info("Found existing user (status: " + existingUser.getStatus() + "). Returning external ID for appropriate handling."); + return externalUserId; + } + + // Case 2: There's already a Spawn user with this email address, but no mapping with this external id + if (existingUserByEmail != null) { + logger.info("Existing user detected in checkOAuthRegistration, email already exists"); + try { + UserIdExternalIdMap externalIdMap = getMappingByUserEmail(email); + User existingUser = externalIdMap.getUser(); + + // Only delete users that have non-active statuses + if (existingUser.getStatus() != null && existingUser.getStatus() != UserStatus.ACTIVE) { + logger.info("Found incomplete user account with email (status: " + existingUser.getStatus() + "). Allowing re-registration."); + try { + userService.deleteUserById(existingUser.getId()); + logger.info("Successfully deleted incomplete user by email, proceeding with registration"); + } catch (Exception e) { + logger.warn("Error deleting incomplete user by email: " + e.getMessage()); + // Continue with registration as the user might have been deleted by another transaction + } + } else { + // For active users, enforce provider consistency + OAuthProvider existingProvider = externalIdMap.getProvider(); + String providerName = existingProvider == OAuthProvider.google ? "Google" : "Apple"; + throw new IncorrectProviderException("Email already exists for a " + providerName + " account. Please login through " + providerName + " instead"); + } + } catch (BaseNotFoundException e) { + logger.warn("User email exists but no mapping found - this may be due to data inconsistency. Attempting graceful repair in registration flow: " + e.getMessage()); + + // Attempt to repair the data inconsistency gracefully + try { + logger.info("Found orphaned user for email: " + email + ", user ID: " + existingUserByEmail.getId()); + + // For users with reasonable data, attempt to create a mapping instead of deleting + if (existingUserByEmail.getStatus() != null && + (existingUserByEmail.getStatus() == UserStatus.ACTIVE || + existingUserByEmail.getStatus() == UserStatus.USERNAME_AND_PHONE_NUMBER || + existingUserByEmail.getStatus() == UserStatus.NAME_AND_PHOTO || + existingUserByEmail.getStatus() == UserStatus.CONTACT_IMPORT)) { + + // This appears to be a legitimate user - attempt to create missing OAuth mapping + logger.info("Attempting to create missing OAuth mapping for legitimate user during registration: " + existingUserByEmail.getId()); + + // Use the provided external ID and provider to create the mapping + try { + createAndSaveMapping(existingUserByEmail, externalUserId, provider); + logger.info("Successfully created missing OAuth mapping during registration for user: " + existingUserByEmail.getId()); + + // Return the external ID to indicate the mapping now exists + return externalUserId; + + } catch (Exception mappingException) { + logger.warn("Failed to create OAuth mapping during registration for orphaned user: " + mappingException.getMessage()); + // Fall through to cleanup logic below + } + } + + // If we couldn't repair the mapping or user has incomplete data, delete the orphaned user + logger.info("Cleaning up orphaned user to allow new registration: " + existingUserByEmail.getId() + " with email: " + existingUserByEmail.getEmail()); + userService.deleteUserById(existingUserByEmail.getId()); + logger.info("Orphaned user deleted: " + existingUserByEmail.getId() + " with email: " + existingUserByEmail.getEmail()); + + } catch (Exception repairException) { + logger.error("Failed to repair or delete orphaned user: " + repairException.getMessage()); + } + } + } + + // Case 3: This is a new user, neither the externalId nor the email exists in our database + logger.info("No existing user found, proceeding with new user registration for external ID: " + externalUserId); + return externalUserId; + } + + @Override + @Transactional + public void createAndSaveMapping(User user, String externalUserId, OAuthProvider provider) { + // Use application-level synchronization per external ID + Object lock = externalIdLocks.computeIfAbsent(externalUserId, k -> new Object()); + + synchronized (lock) { + try { + createAndSaveMappingWithLock(user, externalUserId, provider); + } finally { + // Clean up the lock if no other threads are waiting + externalIdLocks.remove(externalUserId, lock); + } + } + } + + /** + * Internal method to create and save mapping with proper synchronization. + * Handles orphaned mappings and data inconsistencies by cleaning up stale data. + */ + @Transactional(isolation = Isolation.SERIALIZABLE) + private void createAndSaveMappingWithLock(User user, String externalUserId, OAuthProvider provider) { + try { + // Check if mapping already exists + Optional existingMapping = externalIdMapRepository.findById(externalUserId); + if (existingMapping.isPresent()) { + logger.info("Mapping already exists for external ID: " + externalUserId + ". Checking if it belongs to the same user."); + + UserIdExternalIdMap existing = existingMapping.get(); + if (existing.getUser().getId().equals(user.getId())) { + logger.info("Mapping already exists for the same user, no action needed"); + return; + } else { + logger.warn("Mapping exists for different user. This indicates a race condition or data inconsistency. External ID: " + externalUserId + ", existing user: " + existing.getUser().getId() + ", new user: " + user.getId()); + + // Check if the existing mapping points to a deleted/non-existent user + try { + User existingMappedUser = existing.getUser(); + boolean userStillExists = userService.existsByUserId(existingMappedUser.getId()); + + if (!userStillExists) { + logger.warn("Existing mapping points to deleted user. Cleaning up orphaned mapping for external ID: " + externalUserId); + externalIdMapRepository.delete(existing); + externalIdMapRepository.flush(); // Ensure deletion is committed before proceeding + } else { + logger.error("Mapping exists for a different valid user. Cannot proceed with mapping creation. External ID: " + externalUserId + ", existing user: " + existingMappedUser.getId() + ", new user: " + user.getId()); + throw new RuntimeException("OAuth mapping conflict: External ID already mapped to a different active user"); + } + } catch (Exception checkEx) { + logger.warn("Could not verify existing mapped user, treating as orphaned mapping: " + checkEx.getMessage()); + // If we can't verify the user exists, assume it's orphaned and delete the mapping + externalIdMapRepository.delete(existing); + externalIdMapRepository.flush(); + } + } + } + + // Create the new mapping - database constraints should now allow this + UserIdExternalIdMap mapping = new UserIdExternalIdMap(externalUserId, user, provider); + logger.info("Creating mapping for external ID: " + externalUserId + " and user: " + user.getId()); + + UserIdExternalIdMap savedMapping = externalIdMapRepository.save(mapping); + logger.info("Mapping successfully created: " + savedMapping); + + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // This can happen if another thread created the mapping concurrently + logger.warn("Data integrity violation during mapping creation for external ID: " + externalUserId + ". " + e.getMessage()); + + // Check if the existing mapping belongs to our user + Optional existingMapping = externalIdMapRepository.findById(externalUserId); + if (existingMapping.isPresent() && existingMapping.get().getUser().getId().equals(user.getId())) { + logger.info("Concurrent mapping creation detected, but mapping exists for correct user. Operation succeeded."); + return; + } else { + logger.error("Failed to create mapping due to data integrity violation: " + e.getMessage()); + throw new RuntimeException("Unable to complete OAuth mapping creation due to data integrity violation. Please try again."); + } + } catch (Exception e) { + logger.error("Unexpected error creating mapping for external ID " + externalUserId + ": " + e.getMessage()); + throw e; + } + } + + /* ------------------------------ HELPERS ------------------------------ */ + + private boolean mappingExistsByExternalId(String externalUserId) { + logger.info("Checking if mapping exists for external user ID: " + externalUserId); + boolean exists = externalIdMapRepository.existsById(externalUserId); + logger.info("Mapping exists for external user ID " + externalUserId + ": " + exists); + return exists; + } + + private UserIdExternalIdMap getMapping(String externalId) { + try { + logger.info("Fetching mapping for external ID: " + externalId); + UserIdExternalIdMap mapping = externalIdMapRepository.findById(externalId).orElse(null); + if (mapping != null) { + logger.info("Found mapping for external ID: " + externalId + ", associated user ID: " + mapping.getUser().getId()); + } else { + logger.info("No mapping found for external ID: " + externalId); + } + return mapping; + } catch (DataAccessException e) { + logger.error("Database error while fetching mapping for externalUserId( " + externalId + ") : " + e.getMessage()); + throw e; + } catch (Exception e) { + logger.error("Unexpected error while fetching mapping for externalUserId( " + externalId + ") : " + e.getMessage()); + throw e; + } + } + + + + private UserIdExternalIdMap getMappingByUserEmail(String email) { + logger.info("Searching for user mapping by email: " + email); + try { + UserIdExternalIdMap mapping = externalIdMapRepository.findByUserEmail(email) + .orElseThrow(() -> new BaseNotFoundException(EntityType.ExternalIdMap, email, "email")); + logger.info("Found mapping for email: " + email + ", associated with provider: " + mapping.getProvider()); + return mapping; + } catch (BaseNotFoundException e) { + logger.error("No mapping found for email: " + email); + throw e; + } + } + + /** + * Performs comprehensive cleanup of orphaned OAuth data that can occur during concurrent operations. + * This method should be called when data inconsistencies are detected. + * + * @param email The email to check for orphaned data + * @param externalUserId The external user ID to check for orphaned mappings + * @return true if cleanup was performed, false if no cleanup was needed + */ + @Override + public boolean performDataConsistencyCleanup(String email, String externalUserId) { + logger.info("Performing data consistency cleanup for email: " + email + " and external ID: " + externalUserId); + boolean cleanupPerformed = false; + + try { + // Check for orphaned mappings (mappings pointing to deleted users) + Optional orphanedMapping = externalIdMapRepository.findById(externalUserId); + if (orphanedMapping.isPresent()) { + UserIdExternalIdMap mapping = orphanedMapping.get(); + try { + User mappedUser = mapping.getUser(); + if (!userService.existsByUserId(mappedUser.getId())) { + logger.warn("Found orphaned mapping pointing to deleted user. Cleaning up mapping for external ID: " + externalUserId); + externalIdMapRepository.delete(mapping); + cleanupPerformed = true; + } + } catch (Exception e) { + logger.warn("Error checking mapped user existence, deleting potentially orphaned mapping: " + e.getMessage()); + externalIdMapRepository.delete(mapping); + cleanupPerformed = true; + } + } + + // Check for orphaned users (users without OAuth mappings that should have them) + if (userService.existsByEmail(email)) { + try { + User user = userService.getUserByEmail(email); + + // If user has non-active status, they were likely orphaned during a previous OAuth flow + // Try to find their OAuth mapping - if none exists, they're orphaned + if (user.getStatus() != null && user.getStatus() != UserStatus.ACTIVE) { + try { + getMappingByUserEmail(email); + // If we get here, user has a mapping, so they're not orphaned + logger.info("User has OAuth mapping, not orphaned"); + } catch (BaseNotFoundException e) { + // User has no OAuth mapping but exists - this is an orphaned user + logger.warn("Found orphaned user with no OAuth mapping and status: " + user.getStatus() + ". Cleaning up user: " + user.getId()); + userService.deleteUserById(user.getId()); + cleanupPerformed = true; + } + } + } catch (Exception e) { + logger.warn("Error during orphaned user cleanup: " + e.getMessage()); + } + } + + if (cleanupPerformed) { + logger.info("Data consistency cleanup completed for email: " + email); + } else { + logger.info("No cleanup needed for email: " + email); + } + + } catch (Exception e) { + logger.error("Error during data consistency cleanup: " + e.getMessage()); + } + + return cleanupPerformed; + } + + @Override + public boolean isOAuthUser(UUID userId) { + return externalIdMapRepository.existsByUserId(userId); + } + + @Override + public OAuthProvider getOAuthProvider(UUID userId) { + UserIdExternalIdMap mapping = externalIdMapRepository.findByUserId(userId); + if (mapping == null) { + throw new BaseNotFoundException(EntityType.ExternalIdMap); + } else { + return mapping.getProvider(); + } + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthStrategy.java b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthStrategy.java new file mode 100644 index 000000000..e9f4041d0 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/auth/internal/services/OAuthStrategy.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.auth.internal.services; + + +import com.danielagapov.spawn.shared.util.OAuthProvider; + +/** + * Strategy interface for different OAuth providers + */ +public interface OAuthStrategy { + + OAuthProvider getOAuthProvider(); + + String verifyIdToken(String idToken); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/media/internal/services/S3Service.java b/services/auth-service/src/main/java/com/danielagapov/spawn/media/internal/services/S3Service.java new file mode 100644 index 000000000..02fc88140 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/media/internal/services/S3Service.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.media.internal.services; + +/** + * Stub S3 service for the auth microservice. + * Only provides the default profile picture URL constant. + * Full S3 operations are handled by the media service. + */ +public class S3Service { + + private static final String DEFAULT_PFP = "https://spawn-app-bucket.s3.us-east-2.amazonaws.com/default_pfp.png"; + + public static String getDefaultProfilePictureUrlString() { + return DEFAULT_PFP; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java new file mode 100644 index 000000000..b1432b732 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/AsyncConfiguration.java @@ -0,0 +1,28 @@ +package com.danielagapov.spawn.shared.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +/** + * Async configuration for the auth service. + * Provides an executor for async email sending. + */ +@Configuration +@EnableAsync +public class AsyncConfiguration { + + @Bean(name = "emailTaskExecutor") + public Executor emailTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(5); + executor.setQueueCapacity(25); + executor.setThreadNamePrefix("email-"); + executor.initialize(); + return executor; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/JWTFilterConfig.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/JWTFilterConfig.java new file mode 100644 index 000000000..f3eefea36 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/JWTFilterConfig.java @@ -0,0 +1,106 @@ +package com.danielagapov.spawn.shared.config; + +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.auth.internal.services.IJWTService; +import com.danielagapov.spawn.user.internal.services.UserInfoService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.AllArgsConstructor; +import lombok.NonNull; +import org.springframework.context.ApplicationContext; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@AllArgsConstructor +public class JWTFilterConfig extends OncePerRequestFilter { + private final IJWTService jwtService; + private final ApplicationContext context; + private final ILogger logger; + + @Override + protected void doFilterInternal(HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + // Retrieve the Authorization header from the HTTP request + String authHeader = request.getHeader("Authorization"); + + // Check if the Authorization header is missing or does not start with "Bearer " + // If so, skip JWT validation and proceed with the next filter in the chain + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Extract the JWT token from the Authorization header (removing the "Bearer " prefix) + String jwt = authHeader.substring(7); + String username; + try { + username = jwtService.extractUsername(jwt); + } catch (Exception e) { + logger.warn("Failed to extract username. Invalid or expired token: " + e.getMessage()); + filterChain.doFilter(request, response); + return; + } + + // Check if the username was successfully extracted and if the user is not already authenticated + if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { + try { + // Load the UserDetails object for the extracted username using the UserInfoService + // The subject could be a username or email (for OAuth users) + UserDetails userDetails = context.getBean(UserInfoService.class).loadUserByUsername(username); + + // Validate the JWT token against the UserDetails + if (jwtService.isValidToken(jwt, userDetails)) { + /* + * Create an authentication token containing the user details and authorities. + * UsernamePasswordAuthenticationToken is a Spring Security authentication object + * that represents a successfully authenticated user. + * WebAuthenticationDetailsSource is used to build additional details about the authentication request + * This includes information like the remote IP address + */ + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + /* + * Set the authentication in SecurityContextHolder so that Spring Security + * recognizes the user as authenticated for the current request. + */ + SecurityContextHolder.getContext().setAuthentication(token); + } else { + logger.warn("Invalid token, user is not authenticated. Username: " + username); + } + } catch (UsernameNotFoundException e) { + // Try loading by email if username lookup failed (for OAuth users with email-based tokens) + try { + UserDetails userDetails = context.getBean(UserInfoService.class).loadUserByEmail(username); + if (jwtService.isValidToken(jwt, userDetails)) { + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + token.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(token); + } else { + logger.warn("Invalid token, user is not authenticated. Email/username: " + username); + } + } catch (Exception emailException) { + logger.warn("User not found by username or email: " + username + ": " + emailException.getMessage()); + } + } catch (Exception e) { + logger.error("Error during authentication: " + e.getMessage()); + } + } + } catch (Exception e) { + logger.error("Unexpected error in JWT filter: " + e.getMessage()); + // Continue with filter chain even if JWT processing fails + } + + // Proceed with the next filter in the chain + filterChain.doFilter(request, response); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java new file mode 100644 index 000000000..b92cec37b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java @@ -0,0 +1,164 @@ +package com.danielagapov.spawn.shared.config; + +import com.danielagapov.spawn.user.internal.services.UserInfoService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.util.matcher.RegexRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; + +import java.util.Arrays; +import java.util.List; +import jakarta.servlet.http.HttpServletRequest; + +/** + * Security configuration for the auth microservice. + * Simplified to only handle auth-related endpoints. + */ +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity +public class SecurityConfig { + private final JWTFilterConfig jwtFilterConfig; + private final UserInfoService userInfoService; + + private final String[] whitelistedUrls = new String[] { + "/api/v1/auth/refresh-token", + "/api/v1/auth/register/verification/send", + "/api/v1/auth/register/oauth", + "/api/v1/auth/register/verification/check", + "/api/v1/auth/sign-in", + "/api/v1/auth/login", + "/actuator/health", + "/actuator/info", + }; + + private final String[] whitelistedUrlPatterns = new String[] { + "/api/v1/auth/accept-tos/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + "/api/v1/auth/complete-contact-import/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}", + }; + + private static final String[] onboardingUrls = new String[] { + "/api/v1/auth/register/verification/check", + "/api/v1/auth/user/details", + "/api/v1/auth/accept-tos/**", + "/api/v1/auth/complete-contact-import/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> cors.configurationSource(request -> { + CorsConfiguration configuration = new CorsConfiguration(); + + String environment = System.getProperty("spring.profiles.active", "dev"); + boolean isProduction = "prod".equals(environment) || "production".equals(environment); + + if (isProduction) { + configuration.setAllowedOrigins(List.of( + "https://getspawn.com", + "https://admin.getspawn.com", + "https://getspawn.com/admin" + )); + } else { + configuration.setAllowedOrigins(List.of( + "https://getspawn.com", + "https://admin.getspawn.com", + "https://getspawn.com/admin", + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8081", + "http://localhost:4200", + "http://localhost:8100", + "http://127.0.0.1:3000", + "http://127.0.0.1:8080", + "capacitor://localhost" + )); + } + + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("Authorization", "X-Refresh-Token", "Content-Type", "Accept")); + configuration.setExposedHeaders(List.of("Authorization", "X-Refresh-Token")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + return configuration; + })) + .csrf(AbstractHttpConfigurer::disable) + .headers(headers -> headers + .frameOptions(frame -> frame.deny()) + .contentTypeOptions(contentType -> {}) + ) + .authorizeHttpRequests(authorize -> { + authorize.requestMatchers(whitelistedUrls).permitAll(); + + for (String pattern : whitelistedUrlPatterns) { + authorize.requestMatchers(RegexRequestMatcher.regexMatcher(HttpMethod.POST, pattern)).permitAll(); + } + + for (String pattern : onboardingUrls) { + authorize.requestMatchers(pattern).hasRole("ONBOARDING"); + } + + authorize.requestMatchers("/api/v1/auth/quick-sign-in").hasAnyRole("ONBOARDING","ACTIVE"); + authorize.requestMatchers("/api/v1/auth/**").hasAnyRole("ACTIVE", "ONBOARDING"); + authorize.anyRequest().authenticated(); + }) + .exceptionHandling(e -> e + .authenticationEntryPoint((request, response, authException) -> { + String clientIp = getClientIpAddress(request); + String requestUrl = request.getRequestURL().toString(); + + System.out.println("Authentication failed - IP: " + clientIp + + ", URL: " + requestUrl + + ", Error: " + authException.getMessage()); + + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType("application/json"); + response.getWriter().write("{\"error\":\"Authentication required\"}"); + }) + ) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(jwtFilterConfig, UsernamePasswordAuthenticationFilter.class) + ; + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception { + return config.getAuthenticationManager(); + } + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService() { + return userInfoService; + } + + private String getClientIpAddress(HttpServletRequest request) { + String xForwardedForHeader = request.getHeader("X-Forwarded-For"); + if (xForwardedForHeader == null || xForwardedForHeader.isEmpty()) { + return request.getRemoteAddr(); + } else { + return xForwardedForHeader.split(",")[0].trim(); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java new file mode 100644 index 000000000..afcc9e7b1 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.shared.events.redis; + +/** + * Redis Pub/Sub channel names for cross-service events. + *

+ * Centralised here so publishers and subscribers reference the same channel names. + * As more services are extracted, add new channels here. + */ +public final class RedisEventChannels { + + private RedisEventChannels() { + // utility class + } + + /** Published by auth-service when a new user completes registration. */ + public static final String USER_REGISTERED = "events:user-registered"; + + /** Published by auth-service when a user accepts Terms of Service. */ + public static final String USER_TOS_ACCEPTED = "events:user-tos-accepted"; + + /** Published by activity-service (future) when a new activity is created. */ + public static final String ACTIVITY_CREATED = "events:activity-created"; + + /** Published by activity-service (future) when a user joins/leaves an activity. */ + public static final String ACTIVITY_PARTICIPATION_CHANGED = "events:activity-participation-changed"; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java new file mode 100644 index 000000000..87e9f46d1 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java @@ -0,0 +1,47 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +/** + * Publishes domain events to Redis Pub/Sub channels. + *

+ * Events are serialised to JSON so any subscriber (regardless of language or + * framework) can consume them. Use {@link RedisEventChannels} for channel names. + */ +@Component +public class RedisEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(RedisEventPublisher.class); + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisEventPublisher(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + /** + * Publish an event to the given Redis channel. + * + * @param channel one of {@link RedisEventChannels} constants + * @param event the event object (will be serialised to JSON) + */ + public void publish(String channel, Object event) { + try { + String json = objectMapper.writeValueAsString(event); + redisTemplate.convertAndSend(channel, json); + log.info("Published event to channel '{}': {}", channel, json); + } catch (JsonProcessingException e) { + log.error("Failed to serialise event for channel '{}': {}", channel, e.getMessage()); + } catch (Exception e) { + log.error("Failed to publish event to channel '{}': {}", channel, e.getMessage()); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java new file mode 100644 index 000000000..c5de14397 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.shared.events.redis; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +/** + * Event published via Redis Pub/Sub when a new user registers. + *

+ * Consumed by the monolith to initialise default activity types, + * send welcome notifications, etc. + */ +public record UserRegisteredEvent( + UUID userId, + String email, + String username, + String provider, // "email", "google", or "apple" + Instant registeredAt +) implements Serializable { +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java new file mode 100644 index 000000000..5021a09e4 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.UserField; + +public class AccountAlreadyExistsException extends FieldAlreadyExistsException { + public AccountAlreadyExistsException(String message) { + super(message, UserField.EXTERNAL_ID); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java new file mode 100644 index 000000000..28b338f5b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class AccountNotFoundException extends RuntimeException { + public AccountNotFoundException(String message) { + super(message); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java new file mode 100644 index 000000000..9a56e9d80 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java @@ -0,0 +1,22 @@ +package com.danielagapov.spawn.shared.exceptions; + +import java.util.UUID; + +public class ActivityFullException extends RuntimeException { + private final UUID activityId; + private final Integer participantLimit; + + public ActivityFullException(UUID activityId, Integer participantLimit) { + super(String.format("Activity %s is full. Maximum participants: %d", activityId, participantLimit)); + this.activityId = activityId; + this.participantLimit = participantLimit; + } + + public UUID getActivityId() { + return activityId; + } + + public Integer getParticipantLimit() { + return participantLimit; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java new file mode 100644 index 000000000..e5db8d721 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.exceptions; + +import java.util.UUID; + +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; + +public class ActivityNotFoundException extends BaseNotFoundException{ + public ActivityNotFoundException(UUID id) { + super(EntityType.Activity, id); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java new file mode 100644 index 000000000..fd40b897f --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.exceptions.ApplicationException; + +public class ActivityTypeValidationException extends ApplicationException { + public ActivityTypeValidationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java new file mode 100644 index 000000000..689eb7789 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class ApplicationException extends RuntimeException { + public ApplicationException(String message, Throwable cause) { + super(message, cause); + } + public ApplicationException(String message) {super(message);} +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java new file mode 100644 index 000000000..eb3f5a5dd --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java @@ -0,0 +1,10 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +public class BaseDeleteException extends RuntimeException { + public BaseDeleteException(String message) { + super("Could not delete entity: " + message); + } + public BaseDeleteException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java new file mode 100644 index 000000000..12438393c --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class BaseExceptionHandler { + + @ExceptionHandler(BaseNotFoundException.class) + public ResponseEntity handleBaseNotFoundException(BaseNotFoundException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BasesNotFoundException.class) + public ResponseEntity handleBasesNotFoundException(BasesNotFoundException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BaseSaveException.class) + public ResponseEntity handleBaseSaveException(BaseSaveException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java new file mode 100644 index 000000000..80028e381 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import com.danielagapov.spawn.shared.util.EntityType; + +import java.util.UUID; + +public class BaseNotFoundException extends RuntimeException { + public final EntityType entityType; + + public BaseNotFoundException(EntityType et) { + super(et + " entity not found"); + this.entityType = et; + } + + public BaseNotFoundException(EntityType et, UUID id) { + super(et + " entity not found with ID: " + id); + this.entityType = et; + } + + /** + * + * @param et The entity type that could not be found. + * @param identifier this could be something like a user's email or username + * @param identifierType to indicate if it's an email, username, or something else in the print statement. + */ + public BaseNotFoundException(EntityType et, String identifier, String identifierType) { + super(et + " entity not found with " + identifierType + ": " + identifier); + this.entityType = et; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java new file mode 100644 index 000000000..cc2208c7f --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +public class BaseSaveException extends RuntimeException { + public BaseSaveException(String message) { + super("failed to save an entity: " + message); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java new file mode 100644 index 000000000..59a4ef440 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import com.danielagapov.spawn.shared.util.EntityType; + +public class BasesNotFoundException extends RuntimeException { + public final EntityType entityType; + public BasesNotFoundException(EntityType type) { + super(type + "'s not found."); + this.entityType = type; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java new file mode 100644 index 000000000..a3da2be96 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class DatabaseException extends RuntimeException { + public DatabaseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java new file mode 100644 index 000000000..01e45bdf8 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.EMAIL; + +public class EmailAlreadyExistsException extends FieldAlreadyExistsException { + public EmailAlreadyExistsException(String message) { + super(message, EMAIL); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java new file mode 100644 index 000000000..99a63edee --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class EmailVerificationException extends RuntimeException { + public EmailVerificationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java new file mode 100644 index 000000000..4ca5cbde8 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.EntityType; + +import java.util.UUID; + +public class EntityAlreadyExistsException extends RuntimeException { + public EntityAlreadyExistsException(EntityType entityType, UUID id) { + super("Entity already exists for type, " + entityType.getDescription() + " with UUID: " + id); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java new file mode 100644 index 000000000..d4d8db100 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.UserField; + +public class FieldAlreadyExistsException extends RuntimeException { + protected final UserField field; + + public FieldAlreadyExistsException(String message, UserField field) { + super(message); + this.field = field; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java new file mode 100644 index 000000000..0160b702b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,192 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @Autowired + private ILogger logger; + + /** + * Handle authentication-related exceptions + */ + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity> handleAuthenticationException(Exception ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + // Log the full error internally with unique ID + logger.error("Authentication error [" + errorId + "]: " + ex.getMessage()); + + // Return generic message to prevent user enumeration + Map response = createErrorResponse( + "AUTHENTICATION_FAILED", + "Invalid credentials", + HttpStatus.UNAUTHORIZED, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + } + + /** + * Handle entity not found exceptions + */ + @ExceptionHandler(BaseNotFoundException.class) + public ResponseEntity> handleBaseNotFoundException(BaseNotFoundException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.warn("Entity not found [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "RESOURCE_NOT_FOUND", + "The requested resource was not found", + HttpStatus.NOT_FOUND, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); + } + + /** + * Handle save operation exceptions + */ + @ExceptionHandler(BaseSaveException.class) + public ResponseEntity> handleBaseSaveException(BaseSaveException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.error("Save operation failed [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "OPERATION_FAILED", + "Operation could not be completed", + HttpStatus.INTERNAL_SERVER_ERROR, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Handle security exceptions + */ + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.error("Security violation [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "SECURITY_VIOLATION", + "Security policy violation", + HttpStatus.FORBIDDEN, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + } + + /** + * Handle illegal argument exceptions (validation errors) + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.warn("Validation error [" + errorId + "]: " + ex.getMessage()); + + // For validation errors, we can be more specific but still safe + String sanitizedMessage = sanitizeValidationMessage(ex.getMessage()); + + Map response = createErrorResponse( + "VALIDATION_ERROR", + sanitizedMessage, + HttpStatus.BAD_REQUEST, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * Handle all other exceptions + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + // Log full stack trace for debugging + logger.error("Unexpected error [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "INTERNAL_ERROR", + "An unexpected error occurred", + HttpStatus.INTERNAL_SERVER_ERROR, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Creates a standardized error response + */ + private Map createErrorResponse(String errorCode, String message, HttpStatus status, String errorId) { + Map response = new HashMap<>(); + response.put("error", true); + response.put("errorCode", errorCode); + response.put("message", message); + response.put("status", status.value()); + response.put("timestamp", LocalDateTime.now().toString()); + response.put("errorId", errorId); // For support purposes + + return response; + } + + /** + * Sanitizes validation messages to prevent information leakage + */ + private String sanitizeValidationMessage(String message) { + if (message == null) { + return "Invalid input provided"; + } + + // Remove any potential sensitive information patterns + String sanitized = message.toLowerCase(); + + // Check for potentially sensitive patterns and replace with generic messages + if (sanitized.contains("sql") || sanitized.contains("database") || sanitized.contains("constraint")) { + return "Invalid input provided"; + } + + if (sanitized.contains("file") && (sanitized.contains("size") || sanitized.contains("type"))) { + return message; // File validation messages are generally safe to show + } + + if (sanitized.contains("password") && sanitized.contains("requirement")) { + return message; // Password requirement messages are safe + } + + // For other validation messages, return as-is if they seem safe + if (message.length() < 100 && !message.contains("Exception") && !message.contains("Error")) { + return message; + } + + return "Invalid input provided"; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java new file mode 100644 index 000000000..2e30375c4 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class IncorrectProviderException extends RuntimeException { + public IncorrectProviderException(String message) { + super(message); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java new file mode 100644 index 000000000..1b924acbb --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions.Logger; + +public interface ILogger { + void info(String message); + + void warn(String message); + + void error(String message); + + +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java new file mode 100644 index 000000000..15df3fe09 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java @@ -0,0 +1,40 @@ +package com.danielagapov.spawn.shared.exceptions.Logger; + +import org.springframework.stereotype.Service; + +@Service +public final class Logger implements ILogger { + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Logger.class); + + public void info(String message) { + logger.info(formatMessageWithCallerInfo(message)); + } + + public void warn(String message) { + logger.warn(formatMessageWithCallerInfo(message)); + } + + public void error(String message) { + logger.error(formatMessageWithCallerInfo(message)); + } + + /** + * Formats a message with caller information (file name, line number, and method name) + * + * @param message The original message to format + * @return A formatted message with caller information + */ + private String formatMessageWithCallerInfo(String message) { + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + // Index 0 is getStackTrace, index 1 is this method, index 2 is the logging method (info/warn/error), + // and index 3 is the actual caller we want + StackTraceElement caller = stackTrace[3]; + + String fileName = caller.getFileName(); + String methodName = caller.getMethodName(); + int lineNumber = caller.getLineNumber(); + + // Format the message with caller information: [FileName:LineNumber] MethodName - Message + return String.format("[%s:%d] %s - %s", fileName, lineNumber, methodName, message); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java new file mode 100644 index 000000000..9bf9bcbc5 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.shared.exceptions; + +/** + * Exception thrown when an OAuth provider service is unavailable + */ +public class OAuthProviderUnavailableException extends RuntimeException { + public OAuthProviderUnavailableException(String message) { + super(message); + } + + public OAuthProviderUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java new file mode 100644 index 000000000..1b56c23b2 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.PHONE_NUMBER; + +public class PhoneNumberAlreadyExistsException extends FieldAlreadyExistsException { + public PhoneNumberAlreadyExistsException(String message) { + super(message, PHONE_NUMBER); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java new file mode 100644 index 000000000..af6fe6bb0 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java @@ -0,0 +1,4 @@ +package com.danielagapov.spawn.shared.exceptions.Token; + +public class BadTokenException extends RuntimeException { +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java new file mode 100644 index 000000000..a1953113e --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions.Token; + +public class TokenNotFoundException extends RuntimeException { + public TokenNotFoundException(String message) { + super(message); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java new file mode 100644 index 000000000..7b8d9853e --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.shared.exceptions; + +/** + * Exception thrown when an OAuth token has expired + */ +public class TokenExpiredException extends RuntimeException { + public TokenExpiredException(String message) { + super(message); + } + + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java new file mode 100644 index 000000000..c6943a1c8 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class TooManyAttemptsException extends RuntimeException{ + public TooManyAttemptsException(String message) { + super(message); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java new file mode 100644 index 000000000..53ed45242 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.USERNAME; + +public class UsernameAlreadyExistsException extends FieldAlreadyExistsException { + public UsernameAlreadyExistsException(String message) { + super(message, USERNAME); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java new file mode 100644 index 000000000..f0e45d5fb --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/FeignConfig.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.feign; + +import feign.Logger; +import feign.Request; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +/** + * Feign client configuration for inter-service HTTP calls. + */ +@Configuration +public class FeignConfig { + + /** + * Log level for Feign calls. BASIC logs method, URL, response status, and execution time. + */ + @Bean + Logger.Level feignLoggerLevel() { + return Logger.Level.BASIC; + } + + /** + * Connection and read timeouts for Feign calls. + */ + @Bean + public Request.Options requestOptions() { + return new Request.Options( + 5, TimeUnit.SECONDS, // connect timeout + 10, TimeUnit.SECONDS, // read timeout + true // follow redirects + ); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java new file mode 100644 index 000000000..9a78b4add --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java @@ -0,0 +1,42 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.UUID; + +/** + * Feign client for calling the monolith's user endpoints. + *

+ * Once the auth-service has its own database (auth_db), it will no longer have + * direct access to the user table. This client provides the bridge, allowing + * the auth-service to look up user data via REST instead of direct DB access. + *

+ * The circuit breaker "monolith" is configured in application.properties + * via Resilience4j, so a monolith outage does not cascade to the auth service. + */ +@FeignClient( + name = "monolith-user-client", + url = "${services.monolith.url}", + fallbackFactory = MonolithUserClientFallbackFactory.class +) +public interface MonolithUserClient { + + @GetMapping("/api/v1/users/{id}") + BaseUserDTO getUserById(@PathVariable("id") UUID id); + + @GetMapping("/api/v1/users/by-username") + BaseUserDTO getUserByUsername(@RequestParam("username") String username); + + @GetMapping("/api/v1/users/by-email") + BaseUserDTO getUserByEmail(@RequestParam("email") String email); + + @GetMapping("/api/v1/users/exists/by-username") + boolean existsByUsername(@RequestParam("username") String username); + + @GetMapping("/api/v1/users/exists/by-email") + boolean existsByEmail(@RequestParam("email") String email); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java new file mode 100644 index 000000000..01852e0db --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java @@ -0,0 +1,58 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +/** + * Fallback factory for {@link MonolithUserClient}. + *

+ * When the monolith is unreachable or the circuit breaker is open, these + * fallback methods are invoked. They log the failure and throw a runtime + * exception so the caller can handle the error appropriately. + */ +@Component +public class MonolithUserClientFallbackFactory implements FallbackFactory { + + private static final Logger log = LoggerFactory.getLogger(MonolithUserClientFallbackFactory.class); + + @Override + public MonolithUserClient create(Throwable cause) { + return new MonolithUserClient() { + + @Override + public BaseUserDTO getUserById(UUID id) { + log.error("Fallback: monolith unreachable when fetching user by id {}. Cause: {}", id, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public BaseUserDTO getUserByUsername(String username) { + log.error("Fallback: monolith unreachable when fetching user by username {}. Cause: {}", username, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public BaseUserDTO getUserByEmail(String email) { + log.error("Fallback: monolith unreachable when fetching user by email {}. Cause: {}", email, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public boolean existsByUsername(String username) { + log.error("Fallback: monolith unreachable when checking username exists {}. Cause: {}", username, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + + @Override + public boolean existsByEmail(String email) { + log.error("Fallback: monolith unreachable when checking email exists {}. Cause: {}", email, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + } + }; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EmailTemplates.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EmailTemplates.java new file mode 100644 index 000000000..a7df2cffb --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EmailTemplates.java @@ -0,0 +1,39 @@ +package com.danielagapov.spawn.shared.util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +/** + * This class is used for retrieving HTML email templates from resources/templates. + * Auth-service version: uses standard Java IO instead of AWS SDK IoUtils. + */ +public class EmailTemplates { + private static String VERIFY_EMAIL_BODY = null; + private static String EMAIL_VERIFICATION_CODE_BODY = null; + + public static String getVerifyEmailBody() { + if (VERIFY_EMAIL_BODY == null) { + VERIFY_EMAIL_BODY = readHTMLFile("templates/verifyEmailBody.html"); + } + return VERIFY_EMAIL_BODY; + } + + public static String getEmailVerificationCodeBody() { + if (EMAIL_VERIFICATION_CODE_BODY == null) { + EMAIL_VERIFICATION_CODE_BODY = readHTMLFile("templates/emailVerificationCode.html"); + } + return EMAIL_VERIFICATION_CODE_BODY; + } + + private static String readHTMLFile(String resourcePath) { + try (InputStream inputStream = EmailTemplates.class.getClassLoader().getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new RuntimeException("Template not found: " + resourcePath); + } + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new RuntimeException("Failed to read template: " + resourcePath, e); + } + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java new file mode 100644 index 000000000..4f3e9a72b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.shared.util; + +public enum EntityType { + // Base Entities + ChatMessage("Chat Message"), + Activity("Activity"), + ActivityType("ActivityType"), + + User("User"), + FriendRequest("Friend Request"), + BetaAccessSignUp("Beta Access Sign Up"), + + // Related to Base Entities + Location("Location"), + ChatMessageLike("Chat Message Like"), + ActivityUser("Activity User"), + + // Unrelated to Base Entities + ExternalIdMap("External Id Map"), + ReportedContent("Reported Content"), + FeedbackSubmission("Feedback Submission"); + + private final String description; + + EntityType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java new file mode 100644 index 000000000..c4e11bbf0 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.util; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ErrorResponse { + private String message; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java new file mode 100644 index 000000000..1601d1603 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java @@ -0,0 +1,213 @@ +package com.danielagapov.spawn.shared.util; + +import java.util.regex.Pattern; + +/** + * Utility class for input validation and sanitization to prevent injection attacks + * and ensure data integrity + */ +public class InputValidationUtil { + + // Regex patterns for validation + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]{3,30}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern PHONE_PATTERN = Pattern.compile("^\\+?[1-9]\\d{6,14}$"); // E.164 format (7-15 digits) + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z\\s'-]{1,100}$"); + private static final Pattern SAFE_TEXT_PATTERN = Pattern.compile("^[a-zA-Z0-9\\s.,!?'-]{1,500}$"); + + // Dangerous patterns to detect injection attempts + private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile( + "(?i).*(union|select|insert|update|delete|drop|create|alter|exec|script|javascript|vbscript|onload|onerror).*" + ); + private static final Pattern XSS_PATTERN = Pattern.compile( + "(?i).*(]*>", ""); + + // Remove script-related content + sanitized = sanitized.replaceAll("(?i)(javascript:|vbscript:|data:)", ""); + + // Remove SQL injection attempts + sanitized = sanitized.replaceAll("(?i)(union|select|insert|update|delete|drop|create|alter|exec)", ""); + + // Remove path traversal attempts + sanitized = sanitized.replaceAll("(\\.\\./|\\.\\\\)", ""); + + // Trim whitespace + sanitized = sanitized.trim(); + + return sanitized; + } + + /** + * Sanitizes username by removing invalid characters + */ + public static String sanitizeUsername(String username) { + if (username == null) { + return null; + } + + // Keep only allowed characters + String sanitized = username.replaceAll("[^a-zA-Z0-9._-]", ""); + + // Ensure length constraints + if (sanitized.length() > 30) { + sanitized = sanitized.substring(0, 30); + } + + return sanitized.trim(); + } + + /** + * Sanitizes email by converting to lowercase and trimming + */ + public static String sanitizeEmail(String email) { + if (email == null) { + return null; + } + + return email.trim().toLowerCase(); + } + + /** + * Validates password strength + */ + public static boolean isStrongPassword(String password) { + if (password == null || password.length() < 8) { + return false; + } + + boolean hasUpper = password.chars().anyMatch(Character::isUpperCase); + boolean hasLower = password.chars().anyMatch(Character::isLowerCase); + boolean hasDigit = password.chars().anyMatch(Character::isDigit); + boolean hasSpecial = password.chars().anyMatch(ch -> "!@#$%^&*()_+-=[]{}|;:,.<>?".indexOf(ch) >= 0); + + return hasUpper && hasLower && hasDigit && hasSpecial && !containsDangerousContent(password); + } + + /** + * Checks if content contains potentially dangerous patterns + */ + private static boolean containsDangerousContent(String content) { + if (content == null) { + return false; + } + + String lowerContent = content.toLowerCase(); + + return SQL_INJECTION_PATTERN.matcher(lowerContent).matches() || + XSS_PATTERN.matcher(lowerContent).matches() || + PATH_TRAVERSAL_PATTERN.matcher(lowerContent).matches(); + } + + /** + * Validates UUID format + */ + public static boolean isValidUUID(String uuid) { + if (uuid == null || uuid.trim().isEmpty()) { + return false; + } + + Pattern uuidPattern = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ); + + return uuidPattern.matcher(uuid.trim()).matches(); + } + + /** + * Validates that a string is within allowed length limits + */ + public static boolean isValidLength(String text, int minLength, int maxLength) { + if (text == null) { + return minLength == 0; // null is only valid if minimum length is 0 + } + + int length = text.trim().length(); + return length >= minLength && length <= maxLength; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java new file mode 100644 index 000000000..03b089f44 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java @@ -0,0 +1,39 @@ +package com.danielagapov.spawn.shared.util; + +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.UUID; + +/** + * Utility methods for consistent logging across the application + */ +public final class LoggingUtils { + + /** + * Format user information for logging, including ID, first name, last name, and username + * + * @param user The user entity + * @return Formatted string with user details + */ + public static String formatUserInfo(User user) { + if (user == null) { + return "null user"; + } + return String.format("%s with name: %s and username: %s", + user.getId(), user.getName(), user.getUsername()); + } + + /** + * Format user ID for logging when only the ID is available + * Include a note that the full user info is not available + * + * @param userId The user ID + * @return Formatted string with user ID + */ + public static String formatUserIdInfo(UUID userId) { + if (userId == null) { + return "null userId"; + } + return userId.toString() + " (full user info not available)"; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java new file mode 100644 index 000000000..1bf4b19f7 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java @@ -0,0 +1,5 @@ +package com.danielagapov.spawn.shared.util; + +public enum OAuthProvider { + google, apple +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java new file mode 100644 index 000000000..3d2f04e0e --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java @@ -0,0 +1,157 @@ +package com.danielagapov.spawn.shared.util; + +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Utility class for phone number matching and comparison that doesn't make assumptions + * about country codes. This replaces the unreliable approach of forcing +1 on all numbers. + */ +@Component +public final class PhoneNumberMatchingUtil { + + private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("[^0-9]"); + private static final Pattern PHONE_VALIDATION_PATTERN = Pattern.compile("^\\+?[1-9]\\d{7,14}$"); + + /** + * Normalizes a phone number for storage without making country code assumptions. + * Only cleans formatting but preserves the actual number structure. + */ + public static String normalizeForStorage(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return null; + } + + // Remove all non-digit characters except + + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + + // If it's empty after cleaning, return null + if (cleaned.isEmpty()) { + return null; + } + + // Validate basic format (must have + and reasonable length) + if (cleaned.startsWith("+") && PHONE_VALIDATION_PATTERN.matcher(cleaned).matches()) { + return cleaned; + } + + // If no + prefix and looks like a reasonable number, keep as-is for now + // Let the user or admin decide the country code later + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(cleaned).replaceAll(""); + if (digitsOnly.length() >= 7 && digitsOnly.length() <= 15) { + return cleaned.startsWith("+") ? cleaned : digitsOnly; + } + + return null; // Invalid format + } + + /** + * Generates multiple possible formats for a phone number to enable flexible matching. + * This allows us to match phone numbers even when they're stored in different formats. + */ + public static Set generateMatchingVariants(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return Collections.emptySet(); + } + + Set variants = new HashSet<>(); + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(phoneNumber).replaceAll(""); + + if (digitsOnly.length() < 7) { + return Collections.emptySet(); // Too short to be valid + } + + // Add the original (cleaned) + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + if (!cleaned.isEmpty()) { + variants.add(cleaned); + } + + // Add digits-only version + variants.add(digitsOnly); + + // If it already has a country code, add without it + if (cleaned.startsWith("+")) { + variants.add(digitsOnly); + } + + // For common formats, add likely variants + if (digitsOnly.length() == 10) { + // Could be US/Canada without country code + variants.add("+1" + digitsOnly); + variants.add("1" + digitsOnly); + } else if (digitsOnly.length() == 11 && digitsOnly.startsWith("1")) { + // Could be US/Canada with 1 prefix + variants.add("+" + digitsOnly); + variants.add(digitsOnly.substring(1)); // Remove the leading 1 + variants.add("+1" + digitsOnly.substring(1)); + } + + // Add common international prefixes for the same base number + if (!digitsOnly.startsWith("1") && digitsOnly.length() >= 9 && digitsOnly.length() <= 11) { + // Could be other countries - add some common patterns but don't assume + variants.add("+" + digitsOnly); + } + + return variants; + } + + /** + * Checks if two phone numbers could be the same, considering various formatting differences. + */ + public static boolean couldMatch(String phoneNumber1, String phoneNumber2) { + if (phoneNumber1 == null || phoneNumber2 == null) { + return false; + } + + Set variants1 = generateMatchingVariants(phoneNumber1); + Set variants2 = generateMatchingVariants(phoneNumber2); + + // Check if any variants overlap + for (String variant1 : variants1) { + if (variants2.contains(variant1)) { + return true; + } + } + + return false; + } + + /** + * Validates if a phone number has a reasonable format without making country assumptions. + */ + public static boolean isReasonablePhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(cleaned).replaceAll(""); + + // Check for obviously invalid patterns + if (phoneNumber.contains("@") || // Email + phoneNumber.contains("-") && phoneNumber.length() > 20 || // UUID-like + phoneNumber.matches(".*[a-zA-Z].*") && !phoneNumber.contains("@")) { // Letters but not email + return false; + } + + // Check digit count + return digitsOnly.length() >= 7 && digitsOnly.length() <= 15; + } + + /** + * Extracts all possible search variants for database queries. + * Used when searching for users by phone numbers. + */ + public static List getSearchVariants(List phoneNumbers) { + Set allVariants = new HashSet<>(); + + for (String phoneNumber : phoneNumbers) { + allVariants.addAll(generateMatchingVariants(phoneNumber)); + } + + return new ArrayList<>(allVariants); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java new file mode 100644 index 000000000..a128b6041 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java @@ -0,0 +1,96 @@ +package com.danielagapov.spawn.shared.util; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.regex.Pattern; + +@Component +public final class PhoneNumberValidator { + + private static final String PHONE_REGEX = "^\\+?[1-9]\\d{1,14}$"; + private static final Pattern PHONE_PATTERN = Pattern.compile(PHONE_REGEX); + + // Common country codes and their specific patterns + private static final Map COUNTRY_PATTERNS = Map.of( + "+1", "^\\+1[2-9]\\d{2}[2-9]\\d{2}\\d{4}$", // US/Canada + "+44", "^\\+44[1-9]\\d{8,9}$", // UK + "+91", "^\\+91[6-9]\\d{9}$", // India + "+86", "^\\+86[1][3-9]\\d{9}$", // China + "+33", "^\\+33[1-9]\\d{8}$" // France + ); + + public static boolean isValidPhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + String cleanNumber = cleanPhoneNumber(phoneNumber); + + // Basic format validation + if (cleanNumber == null || !PHONE_PATTERN.matcher(cleanNumber).matches()) { + return false; + } + + // Country-specific validation + return validateByCountryCode(cleanNumber); + } + + /** + * Cleans a phone number without making assumptions about country codes. + * This replaces the old approach that defaulted to +1. + */ + public static String cleanPhoneNumber(String phoneNumber) { + if (phoneNumber == null) return null; + + System.out.println("🧹 BACKEND CLEANING PHONE: '" + phoneNumber + "'"); + + // Check if it's obviously not a phone number first + if (!PhoneNumberMatchingUtil.isReasonablePhoneNumber(phoneNumber)) { + System.out.println(" REJECTED: Not a reasonable phone number format"); + return null; + } + + // Use the new matching util for normalization + String normalized = PhoneNumberMatchingUtil.normalizeForStorage(phoneNumber); + System.out.println(" NORMALIZED: '" + normalized + "'"); + + // If we got a clean result, return it + if (normalized != null && !normalized.trim().isEmpty()) { + System.out.println(" FINAL RESULT: '" + normalized + "'"); + return normalized; + } + + System.out.println(" FINAL RESULT: null (invalid)"); + return null; + } + + private static boolean validateByCountryCode(String phoneNumber) { + // If it has a + prefix, try country-specific validation + if (phoneNumber.startsWith("+")) { + for (Map.Entry entry : COUNTRY_PATTERNS.entrySet()) { + if (phoneNumber.startsWith(entry.getKey())) { + return Pattern.compile(entry.getValue()).matcher(phoneNumber).matches(); + } + } + } + + // For numbers without country codes, use general validation + // Don't assume any specific country + String digitsOnly = phoneNumber.replaceAll("[^0-9]", ""); + return digitsOnly.length() >= 7 && digitsOnly.length() <= 15; + } + + public static String getCountryCode(String phoneNumber) { + String cleaned = cleanPhoneNumber(phoneNumber); + if (cleaned == null || !cleaned.startsWith("+")) return null; + + for (String countryCode : COUNTRY_PATTERNS.keySet()) { + if (cleaned.startsWith(countryCode)) { + return countryCode; + } + } + + return null; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java new file mode 100644 index 000000000..0f8d53ab4 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java @@ -0,0 +1,80 @@ +package com.danielagapov.spawn.shared.util; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.function.Predicate; + +/** + * Utility class for implementing retry logic with exponential backoff + */ +public class RetryHelper { + + /** + * Executes a callable with retry logic using exponential backoff + * + * @param callable The operation to retry + * @param maxRetries Maximum number of retry attempts + * @param initialDelay Initial delay before first retry + * @param retryOnException Predicate to determine if exception should trigger retry + * @param Return type of the callable + * @return Result of the successful operation + * @throws Exception The last exception if all retries fail + */ + public static T executeWithRetry( + Callable callable, + int maxRetries, + Duration initialDelay, + Predicate retryOnException) throws Exception { + + Exception lastException = null; + Duration currentDelay = initialDelay; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + return callable.call(); + } catch (Exception e) { + lastException = e; + + if (attempt == maxRetries || !retryOnException.test(e)) { + throw e; + } + + // Wait before retrying + try { + Thread.sleep(currentDelay.toMillis()); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Retry interrupted", ie); + } + + // Exponential backoff + currentDelay = currentDelay.multipliedBy(2); + } + } + + throw lastException; + } + + /** + * Convenience method for OAuth-related retries + */ + public static T executeOAuthWithRetry(Callable callable) throws Exception { + return executeWithRetry( + callable, + 3, + Duration.ofMillis(500), + exception -> isRetryableException(exception) + ); + } + + /** + * Determines if an exception is retryable for OAuth operations + */ + private static boolean isRetryableException(Exception e) { + // Network timeouts, connection errors, etc. + return e instanceof java.net.ConnectException + || e instanceof java.net.SocketTimeoutException + || e instanceof java.io.IOException + || (e.getCause() != null && isRetryableException((Exception) e.getCause())); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserField.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserField.java new file mode 100644 index 000000000..8e8286358 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserField.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.util; + +public enum UserField { + USERNAME, + EMAIL, + PHONE_NUMBER, + EXTERNAL_ID +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java new file mode 100644 index 000000000..b06a206b7 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserMapper.java @@ -0,0 +1,113 @@ +package com.danielagapov.spawn.shared.util; + +import com.danielagapov.spawn.user.api.dto.AuthResponseDTO; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.user.api.dto.UserCreationDTO; +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +/** + * Auth-service version of UserMapper. + * Contains only the mapping methods needed by the auth service. + */ +public final class UserMapper { + + public static BaseUserDTO toDTO(User user) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePictureUrlString(), + user.getHasCompletedOnboarding(), + null + ); + } + + public static BaseUserDTO toDTOWithProvider(User user, String provider) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePictureUrlString(), + user.getHasCompletedOnboarding(), + provider + ); + } + + public static AuthResponseDTO toAuthResponseDTO(User user) { + BaseUserDTO baseUserDTO = toDTO(user); + return new AuthResponseDTO(baseUserDTO, user.getStatus()); + } + + public static AuthResponseDTO toAuthResponseDTO(User user, boolean isOAuthUser) { + BaseUserDTO baseUserDTO = toDTO(user); + return new AuthResponseDTO(baseUserDTO, user.getStatus(), isOAuthUser); + } + + public static AuthResponseDTO toAuthResponseDTO(User user, boolean isOAuthUser, String provider) { + BaseUserDTO baseUserDTO = toDTOWithProvider(user, provider); + return new AuthResponseDTO(baseUserDTO, user.getStatus(), isOAuthUser); + } + + public static List toDTOList(List users) { + return users.stream().map(UserMapper::toDTO).toList(); + } + + public static UserDTO toDTO(User user, List friendUserIds) { + return new UserDTO( + user.getId(), + friendUserIds, + user.getUsername(), + user.getProfilePictureUrlString(), + user.getName(), + user.getBio(), + user.getEmail(), + user.getHasCompletedOnboarding() + ); + } + + public static User toEntity(BaseUserDTO dto) { + return new User( + dto.getId(), + dto.getUsername(), + dto.getProfilePicture(), + dto.getName(), + dto.getBio(), + dto.getEmail() + ); + } + + public static BaseUserDTO toBaseDTO(UserDTO user) { + return new BaseUserDTO( + user.getId(), + user.getName(), + user.getEmail(), + user.getUsername(), + user.getBio(), + user.getProfilePicture(), + user.getHasCompletedOnboarding() + ); + } + + public static UserDTO toDTOFromCreationUserDTO(UserCreationDTO userCreationDTO) { + return new UserDTO( + userCreationDTO.getId(), + null, + userCreationDTO.getUsername(), + null, + userCreationDTO.getName(), + userCreationDTO.getBio(), + userCreationDTO.getEmail(), + false + ); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java new file mode 100644 index 000000000..da317db8e --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.util; + +public enum UserRelationshipType { + FRIEND, + RECOMMENDED_FRIEND, + INCOMING_FRIEND_REQUEST, + OUTGOING_FRIEND_REQUEST +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java new file mode 100644 index 000000000..4b1b39a32 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.shared.util; + +/** + * Each status represents the most recently completed step in the registration process. + * For example, if a user has status EMAIL_VERIFIED, they have completed the email verification step but not the USERNAME_AND_PHONE_NUMBER step. + * ADMIN is a special status for admin users who have access to admin-only endpoints. + */ +public enum UserStatus { + EMAIL_VERIFIED, + USERNAME_AND_PHONE_NUMBER, + NAME_AND_PHOTO, + CONTACT_IMPORT, + ACTIVE, + ADMIN +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java new file mode 100644 index 000000000..f302c4655 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.util; + +import java.security.SecureRandom; + +/** + * Utility class for generating verification codes + */ +public final class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String DIGITS = "0123456789"; + + /** + * Generates a 6-digit verification code + * @return a 6-digit string code + */ + public static String generateVerificationCode() { + StringBuilder code = new StringBuilder(6); + for (int i = 0; i < 6; i++) { + code.append(DIGITS.charAt(RANDOM.nextInt(DIGITS.length()))); + } + return code.toString(); + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java new file mode 100644 index 000000000..395a672fc --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidName annotation + */ +public class NameValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidName constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String name, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (name == null || name.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (name == null || name.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidName(name); + } +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java new file mode 100644 index 000000000..4bbf35206 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidPhoneNumber annotation + */ +public class PhoneNumberValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidPhoneNumber constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String phoneNumber, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (phoneNumber == null || phoneNumber.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidPhoneNumber(phoneNumber); + } +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java new file mode 100644 index 000000000..4f9c65075 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidUsername annotation + */ +public class UsernameValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidUsername constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String username, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (username == null || username.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (username == null || username.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidUsername(username); + } +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java new file mode 100644 index 000000000..e97634e90 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a name follows standard conventions: + * - 1-100 characters long + * - Only letters, spaces, hyphens, and apostrophes + * - No dangerous content (SQL injection, XSS, etc.) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = NameValidator.class) +@Documented +public @interface ValidName { + String message() default "Name must be 1-100 characters and contain only letters, spaces, hyphens, and apostrophes"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java new file mode 100644 index 000000000..d2d28c95b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a phone number follows E.164 format: + * - Optional + prefix + * - 1-15 digits + * - No spaces or special characters (except in display format) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PhoneNumberValidator.class) +@Documented +public @interface ValidPhoneNumber { + String message() default "Phone number must be in valid E.164 format"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java new file mode 100644 index 000000000..3f0b99379 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java @@ -0,0 +1,25 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a username follows standard conventions: + * - 3-30 characters long + * - Only alphanumeric characters, dots, underscores, and hyphens + * - No spaces allowed + * - No dangerous content (SQL injection, XSS, etc.) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +@Documented +public @interface ValidUsername { + String message() default "Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java new file mode 100644 index 000000000..7050059cd --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java @@ -0,0 +1,42 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.validation.ValidName; +import com.danielagapov.spawn.shared.validation.ValidUsername; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@AllArgsConstructor +@Getter +@Setter +@NoArgsConstructor +// Abstract class since interface describes behaviours +public abstract class AbstractUserDTO implements Serializable { + private UUID id; + + @ValidName(optional = true) + private String name; + + @Email(message = "Email must be valid") + private String email; + + @ValidUsername(optional = true) + private String username; + + @Size(max = 500, message = "Bio must not exceed 500 characters") + private String bio; + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; // Check if the same reference + if (obj == null || getClass() != obj.getClass()) return false; // Null check and class check + AbstractUserDTO that = (AbstractUserDTO) obj; // Safe cast + return id != null && id.equals(that.id); // Compare IDs + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java new file mode 100644 index 000000000..b2b41ffeb --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.util.UserStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponseDTO implements Serializable { + private BaseUserDTO user; + private UserStatus status; + private Boolean isOAuthUser; + + public AuthResponseDTO(BaseUserDTO user, UserStatus status) { + this.user = user; + this.status = status; + } + + public AuthResponseDTO(BaseUserDTO user, UserStatus status, boolean isOAuthUser) { + this.user = user; + this.status = status; + this.isOAuthUser = isOAuthUser; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java new file mode 100644 index 000000000..756227769 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.user.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * DTO for email/password authentication and registration. + * + * Note: This DTO is ONLY used for email/password flows where a password is required. + * For OAuth authentication (Google, Apple), use OAuthRegistrationDTO instead, + * which does not have a password field. + * + * Inherits validation from AbstractUserDTO for username, name, email, and bio. + */ +@NoArgsConstructor +@Getter +@Setter +public class AuthUserDTO extends AbstractUserDTO { + @NotBlank(message = "Password is required") + @Size(min = 8, message = "Password must be at least 8 characters") + private String password; + + public AuthUserDTO(UUID id, String name, String email, String username, String bio, String password) { + super(id, name, email, username, bio); + this.password = password; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java new file mode 100644 index 000000000..20b875831 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java @@ -0,0 +1,68 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.util.UserRelationshipType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +public class BaseUserDTO extends AbstractUserDTO { + private String profilePicture; + private Boolean hasCompletedOnboarding; + private String provider; // Auth provider: "google", "apple", or "email" + private UserRelationshipType relationshipStatus; // Relationship status relative to requesting user + private UUID pendingFriendRequestId; // ID of pending friend request if one exists + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = false; // Default value for backward compatibility + this.provider = null; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, Boolean hasCompletedOnboarding) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = null; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, Boolean hasCompletedOnboarding, String provider) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = provider; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + @JsonCreator + public BaseUserDTO( + @JsonProperty("id") UUID id, + @JsonProperty("name") String name, + @JsonProperty("email") String email, + @JsonProperty("username") String username, + @JsonProperty("bio") String bio, + @JsonProperty("profilePicture") String profilePicture, + @JsonProperty("hasCompletedOnboarding") Boolean hasCompletedOnboarding, + @JsonProperty("provider") String provider, + @JsonProperty("relationshipStatus") UserRelationshipType relationshipStatus, + @JsonProperty("pendingFriendRequestId") UUID pendingFriendRequestId) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = provider; + this.relationshipStatus = relationshipStatus; + this.pendingFriendRequestId = pendingFriendRequestId; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java new file mode 100644 index 000000000..762799ccf --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class LoginDTO implements Serializable { + private String usernameOrEmail; + private String password; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java new file mode 100644 index 000000000..c8b308394 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class OptionalDetailsDTO implements Serializable { + private String name; + private byte[] profilePictureData; +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java new file mode 100644 index 000000000..fc726e04b --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * DTO used for password change requests + */ +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class PasswordChangeDTO { + private String currentPassword; + private String newPassword; +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java new file mode 100644 index 000000000..c1a40da0e --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java @@ -0,0 +1,29 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.validation.ValidPhoneNumber; +import com.danielagapov.spawn.shared.validation.ValidUsername; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class UpdateUserDetailsDTO implements Serializable { + @NotNull(message = "User ID is required") + private UUID id; + + @ValidUsername + private String username; + + @ValidPhoneNumber + private String phoneNumber; + + private String password; +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java new file mode 100644 index 000000000..4c1f7c7dd --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * DTO for user creation (legacy OAuth flow). + * Note: This inherits validation from AbstractUserDTO for username, name, email, and bio. + * Password is NOT part of this DTO as it's used for OAuth flows. + */ +@NoArgsConstructor +@Getter +@Setter +public class UserCreationDTO extends AbstractUserDTO { + private byte[] profilePictureData; // raw image uploaded + + public UserCreationDTO(UUID id, String username, byte[] profilePictureData, String name, String bio, String email) { + super(id, name, email, username, bio); + this.profilePictureData = profilePictureData; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java new file mode 100644 index 000000000..609d5cc24 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java @@ -0,0 +1,34 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +public class UserDTO extends BaseUserDTO { + List friendUserIds; + + public UserDTO(UUID id, List friendUserIds, String username, String picture, String name, String bio, String email) { + super(id, name, email, username, bio, picture); + this.friendUserIds = friendUserIds; + } + + @JsonCreator + public UserDTO( + @JsonProperty("id") UUID id, + @JsonProperty("friendUserIds") List friendUserIds, + @JsonProperty("username") String username, + @JsonProperty("profilePicture") String picture, + @JsonProperty("name") String name, + @JsonProperty("bio") String bio, + @JsonProperty("email") String email, + @JsonProperty("hasCompletedOnboarding") Boolean hasCompletedOnboarding) { + super(id, name, email, username, bio, picture, hasCompletedOnboarding); + this.friendUserIds = friendUserIds; + } +} \ No newline at end of file diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java new file mode 100644 index 000000000..f7326ac5c --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/User.java @@ -0,0 +1,146 @@ +package com.danielagapov.spawn.user.internal.domain; + +import com.danielagapov.spawn.shared.util.UserStatus; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.time.Instant; +import java.util.Date; +import java.util.Optional; +import java.util.UUID; + +/* + * Represents a unique Spawn User. + * This is the auth-service version of the User entity (simplified, no notification preferences). + */ +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +@JsonIgnoreProperties({"hibernateLazyInitializer", "handler"}) +@Table( + name = "`user`", + indexes = { + @Index(name = "idx_name", columnList = "name"), + @Index(name = "idx_email", columnList = "email"), + @Index(name = "idx_username", columnList = "username"), + @Index(name = "idx_phone_number", columnList = "phoneNumber") + } +) +public class User implements Serializable { + @Id + @GeneratedValue + private UUID id; + + @Column(nullable = true, unique = true) + private String username; + private String profilePictureUrlString; + + @Column(unique = true, nullable = true) + private String phoneNumber; + + @Column(nullable = true) + private String name; + private String bio; + + @Column(nullable = true, unique = true) + private String email; + private String password; + private Date dateCreated; + + @Column(name = "last_updated") + private Instant lastUpdated; + + @Column(nullable = false) + private UserStatus status; + + @Column(nullable = false, columnDefinition = "BOOLEAN DEFAULT FALSE") + private Boolean hasCompletedOnboarding = false; + + @PrePersist + public void prePersist() { + if (this.lastUpdated == null) { + this.lastUpdated = Instant.now(); + } + if (this.dateCreated == null) { + this.dateCreated = new Date(); + } + } + + @PreUpdate + public void preUpdate() { + this.lastUpdated = Instant.now(); + } + + public User(UUID id, String username, String profilePictureUrlString, String name, String bio, String email) { + this.id = id; + this.username = username; + this.profilePictureUrlString = profilePictureUrlString; + this.name = name; + this.bio = bio; + this.email = email; + this.lastUpdated = Instant.now(); + this.hasCompletedOnboarding = false; + } + + public Optional getOptionalUsername() { + return Optional.ofNullable(username).filter(s -> !s.trim().isEmpty()); + } + + public Optional getOptionalPhoneNumber() { + return Optional.ofNullable(phoneNumber).filter(s -> !s.trim().isEmpty()); + } + + public Optional getOptionalName() { + return Optional.ofNullable(name).filter(s -> !s.trim().isEmpty()); + } + + public Optional getOptionalEmail() { + return Optional.ofNullable(email).filter(s -> !s.trim().isEmpty()); + } + + public Optional getOptionalBio() { + return Optional.ofNullable(bio).filter(s -> !s.trim().isEmpty()); + } + + public Optional getOptionalPassword() { + return Optional.ofNullable(password).filter(s -> !s.trim().isEmpty()); + } + + public boolean hasRequiredFieldsForStatus() { + if (!getOptionalEmail().isPresent()) { + return false; + } + switch (status) { + case EMAIL_VERIFIED: + return true; + case USERNAME_AND_PHONE_NUMBER: + return getOptionalUsername().isPresent() && getOptionalPhoneNumber().isPresent(); + case NAME_AND_PHOTO: + case CONTACT_IMPORT: + case ACTIVE: + return getOptionalUsername().isPresent() && + getOptionalPhoneNumber().isPresent() && + getOptionalName().isPresent(); + default: + return false; + } + } + + public String getDisplayName() { + return getOptionalName() + .or(() -> getOptionalUsername()) + .or(() -> getOptionalEmail().map(email -> email.split("@")[0])) + .orElse("User"); + } + + public void markOnboardingCompleted() { + this.hasCompletedOnboarding = true; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/UserInfo.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/UserInfo.java new file mode 100644 index 000000000..146db7bfb --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/domain/UserInfo.java @@ -0,0 +1,70 @@ +package com.danielagapov.spawn.user.internal.domain; + +import com.danielagapov.spawn.shared.util.UserStatus; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +/** + * This class implements the UserDetails interface which is used by Spring Security to authenticate + * incoming requests. It is populated with the username and password from a User entity. + * For JWTs and in our application, only getPassword and getUsername are needed + */ +public class UserInfo implements UserDetails { + private final String username; + private final String password; + private final UserStatus status; + + public UserInfo(String username, String password, UserStatus status) { + this.username = username; + this.password = password; + this.status = status; + } + + @Override + public Collection getAuthorities() { + String role; + if (status == UserStatus.ADMIN) { + role = "ROLE_ADMIN"; + } else if (status == UserStatus.ACTIVE) { + role = "ROLE_ACTIVE"; + } else { + role = "ROLE_ONBOARDING"; + } + + return List.of(new SimpleGrantedAuthority(role)); + } + + @Override + public String getPassword() { + return password; + } + + @Override + public String getUsername() { + return username; + } + + @Override + public boolean isAccountNonExpired() { + return true; + } + + @Override + public boolean isAccountNonLocked() { + return true; + } + + @Override + public boolean isCredentialsNonExpired() { + return true; + } + + @Override + public boolean isEnabled() { + return true; + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java new file mode 100644 index 000000000..0818bd9bd --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/repositories/IUserRepository.java @@ -0,0 +1,64 @@ +package com.danielagapov.spawn.user.internal.repositories; + +import com.danielagapov.spawn.shared.util.UserStatus; +import com.danielagapov.spawn.user.internal.domain.User; +import org.springframework.data.domain.Limit; +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.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface IUserRepository extends JpaRepository { + // The JpaRepository interface already includes methods like save() and findById() + // Find + Optional findByEmail(String email); + + Optional findByUsername(String username); + + Optional findUserByEmail(String email); + + @Query("SELECT u FROM User u WHERE u.status = :status") + List findAllUsersByStatus(UserStatus status); + + @Query("SELECT u FROM User u WHERE LOWER(u.name) LIKE CONCAT('%', :query, '%') OR LOWER(u.username) LIKE CONCAT('%', :query, '%')") + List findUsersWithPartialMatch(String query, Limit limit); + + // Phone number queries + /** + * Find users by a list of phone numbers. + * This method performs a proper database query instead of loading all users into memory. + * + * @param phoneNumbers List of phone numbers to search for + * @return List of users with matching phone numbers + */ + @Query("SELECT u FROM User u WHERE u.phoneNumber IN :phoneNumbers") + List findByPhoneNumberIn(@Param("phoneNumbers") List phoneNumbers); + + /** + * Find a single user by phone number + * + * @param phoneNumber The phone number to search for + * @return Optional containing the user if found + */ + Optional findByPhoneNumber(String phoneNumber); + + // Exist + boolean existsByUsername(String username); + + boolean existsByEmail(String email); + + boolean existsByPhoneNumber(String phoneNumber); + + boolean existsByEmailAndStatus(String email, UserStatus status); + + @Query(value = "SELECT MAX(u.last_updated) FROM user u " + + "JOIN friendship f ON (u.id = f.user_a_id OR u.id = f.user_b_id) " + + "WHERE (f.user_a_id = :userId OR f.user_b_id = :userId) AND u.id != :userId", nativeQuery = true) + Instant findLatestFriendProfileUpdate(@Param("userId") UUID userId); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java new file mode 100644 index 000000000..2f5431503 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/IUserService.java @@ -0,0 +1,38 @@ +package com.danielagapov.spawn.user.internal.services; + +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.shared.util.UserStatus; +import com.danielagapov.spawn.user.internal.domain.User; + +import java.util.UUID; + +/** + * Minimal user service interface for the auth microservice. + * Contains only the user operations needed by auth services. + */ +public interface IUserService { + + User getUserEntityById(UUID id); + + User getUserEntityByUsername(String username); + + User getUserByEmail(String email); + + User createAndSaveUser(User user); + + User saveEntity(User user); + + UserDTO createAndSaveUserWithProfilePicture(UserDTO user, byte[] profilePicture); + + void deleteUserById(UUID id); + + boolean existsByEmail(String email); + + boolean existsByEmailAndStatus(String email, UserStatus status); + + boolean existsByUsername(String username); + + boolean existsByPhoneNumber(String phoneNumber); + + boolean existsByUserId(UUID userId); +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserInfoService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserInfoService.java new file mode 100644 index 000000000..9e2519f46 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserInfoService.java @@ -0,0 +1,44 @@ +package com.danielagapov.spawn.user.internal.services; + +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.user.internal.domain.UserInfo; +import com.danielagapov.spawn.user.internal.repositories.IUserRepository; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +/** + * This class is used to implement the UserDetailsService interface which Spring Security relies on + * for authenticating requests + */ +@Service +public class UserInfoService implements UserDetailsService { + private final IUserRepository repository; + + public UserInfoService(IUserRepository repository) { + this.repository = repository; + } + + /** + * Retrieves user from repository by username and returns it as a UserDetails object + */ + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = repository.findByUsername(username).orElseThrow(() -> new UsernameNotFoundException(username)); + + return new UserInfo(user.getUsername(), user.getPassword(), user.getStatus()); + } + + /** + * Retrieves user from repository by email and returns it as a UserDetails object + * This is used for OAuth users who may have tokens with email as the subject + */ + public UserDetails loadUserByEmail(String email) throws UsernameNotFoundException { + User user = repository.findByEmail(email).orElseThrow(() -> new UsernameNotFoundException(email)); + + // Return UserInfo using the user's username (or email if username is null) and password + String usernameForAuth = user.getOptionalUsername().orElse(user.getEmail()); + return new UserInfo(usernameForAuth, user.getPassword(), user.getStatus()); + } +} diff --git a/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java new file mode 100644 index 000000000..eb1653af4 --- /dev/null +++ b/services/auth-service/src/main/java/com/danielagapov/spawn/user/internal/services/UserService.java @@ -0,0 +1,99 @@ +package com.danielagapov.spawn.user.internal.services; + +import com.danielagapov.spawn.user.api.dto.UserDTO; +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.util.UserStatus; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.util.UserMapper; +import com.danielagapov.spawn.user.internal.domain.User; +import com.danielagapov.spawn.user.internal.repositories.IUserRepository; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.UUID; + +/** + * Minimal user service implementation for the auth microservice. + * Provides only the user operations needed by auth services. + * This service directly accesses the shared user database table. + */ +@Service +@AllArgsConstructor +public class UserService implements IUserService { + + private final IUserRepository userRepository; + private final ILogger logger; + + @Override + public User getUserEntityById(UUID id) { + return userRepository.findById(id) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User)); + } + + @Override + public User getUserEntityByUsername(String username) { + return userRepository.findByUsername(username) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User)); + } + + @Override + public User getUserByEmail(String email) { + return userRepository.findByEmail(email) + .orElseThrow(() -> new BaseNotFoundException(EntityType.User)); + } + + @Override + public User createAndSaveUser(User user) { + logger.info("Creating new user with email: " + user.getEmail()); + return userRepository.save(user); + } + + @Override + public User saveEntity(User user) { + return userRepository.save(user); + } + + @Override + public UserDTO createAndSaveUserWithProfilePicture(UserDTO user, byte[] profilePicture) { + // In the auth service, we save the user without S3 profile picture upload + // The full profile picture handling is done by the main service + User userEntity = UserMapper.toEntity(user); + userEntity = userRepository.save(userEntity); + return UserMapper.toDTO(userEntity, List.of()); + } + + @Override + public void deleteUserById(UUID id) { + if (!userRepository.existsById(id)) { + throw new BaseNotFoundException(EntityType.User); + } + userRepository.deleteById(id); + } + + @Override + public boolean existsByEmail(String email) { + return userRepository.existsByEmail(email); + } + + @Override + public boolean existsByEmailAndStatus(String email, UserStatus status) { + return userRepository.existsByEmailAndStatus(email, status); + } + + @Override + public boolean existsByUsername(String username) { + return userRepository.existsByUsername(username); + } + + @Override + public boolean existsByPhoneNumber(String phoneNumber) { + return userRepository.existsByPhoneNumber(phoneNumber); + } + + @Override + public boolean existsByUserId(UUID userId) { + return userRepository.existsById(userId); + } +} diff --git a/services/auth-service/src/main/resources/application.properties b/services/auth-service/src/main/resources/application.properties new file mode 100644 index 000000000..508b5c8cc --- /dev/null +++ b/services/auth-service/src/main/resources/application.properties @@ -0,0 +1,84 @@ +spring.application.name=spawn-auth-service +server.port=8081 + +# ============================================================================ +# Database Configuration (Shared MySQL — same as monolith) +# ============================================================================ +spring.datasource.url=${MYSQL_URL} +spring.datasource.username=${MYSQL_USER} +spring.datasource.password=${MYSQL_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver + +# HikariCP Connection Pool +spring.datasource.hikari.maximum-pool-size=5 +spring.datasource.hikari.minimum-idle=1 +spring.datasource.hikari.idle-timeout=600000 +spring.datasource.hikari.max-lifetime=1800000 +spring.datasource.hikari.connection-timeout=20000 + +# JPA/Hibernate +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.open-in-view=false + +# ============================================================================ +# Mail Configuration +# ============================================================================ +spring.mail.host=smtp.gmail.com +spring.mail.port=587 +spring.mail.username=spawnappmarketing@gmail.com +spring.mail.password=${EMAIL_PASS} +spring.mail.properties.mail.smtp.auth=true +spring.mail.properties.mail.smtp.starttls.enable=true +spring.mail.properties.mail.smtp.ssl.trust=smtp.gmail.com + +# ============================================================================ +# OAuth Configuration +# ============================================================================ +google.client.id=${GOOGLE_CLIENT_ID} +apple.client.id=${APPLE_CLIENT_ID} + +# ============================================================================ +# JWT Configuration +# ============================================================================ +jwt.signing-secret=${SIGNING_SECRET:} + +# ============================================================================ +# Redis (Cross-Service Pub/Sub Events) +# ============================================================================ +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# ============================================================================ +# Inter-Service Communication +# ============================================================================ +# Monolith base URL for Feign client calls (user lookup, etc.) +services.monolith.url=${MONOLITH_URL:http://localhost:8080} + +# ============================================================================ +# Resilience4j Circuit Breaker Configuration +# ============================================================================ +resilience4j.circuitbreaker.instances.monolith.register-health-indicator=true +resilience4j.circuitbreaker.instances.monolith.sliding-window-size=10 +resilience4j.circuitbreaker.instances.monolith.minimum-number-of-calls=5 +resilience4j.circuitbreaker.instances.monolith.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.monolith.wait-duration-in-open-state=30s +resilience4j.circuitbreaker.instances.monolith.permitted-number-of-calls-in-half-open-state=3 +resilience4j.circuitbreaker.instances.monolith.automatic-transition-from-open-to-half-open-enabled=true + +resilience4j.timelimiter.instances.monolith.timeout-duration=5s + +# ============================================================================ +# Server Configuration +# ============================================================================ +server.tomcat.threads.max=25 +server.tomcat.threads.min-spare=2 +server.tomcat.max-connections=1000 +server.tomcat.connection-timeout=20000 + +# ============================================================================ +# Actuator (Health Checks) +# ============================================================================ +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always diff --git a/services/auth-service/src/main/resources/templates/emailVerificationCode.html b/services/auth-service/src/main/resources/templates/emailVerificationCode.html new file mode 100644 index 000000000..d632a6802 --- /dev/null +++ b/services/auth-service/src/main/resources/templates/emailVerificationCode.html @@ -0,0 +1,100 @@ + + + + + + Your Verification Code + + + +

+
Your Verification Code
+
+ Please use the verification code below to complete your account verification. This code will expire in 10 minutes.

+ Note: The verification code is also included in the email subject line for easy access. +
+
[VERIFICATION_CODE]
+ +
+ This code expires at [EXPIRY_TIME] +
+
+ + \ No newline at end of file diff --git a/services/auth-service/src/main/resources/templates/verifyAccountPage.html b/services/auth-service/src/main/resources/templates/verifyAccountPage.html new file mode 100644 index 000000000..084f40bdc --- /dev/null +++ b/services/auth-service/src/main/resources/templates/verifyAccountPage.html @@ -0,0 +1,114 @@ + + + + + + Account Verification + + + + +
+
Account Verification
+
+ + Your account has been successfully verified! You can now log in and start using our app. +
+ +
+ + + \ No newline at end of file diff --git a/services/auth-service/src/main/resources/templates/verifyEmailBody.html b/services/auth-service/src/main/resources/templates/verifyEmailBody.html new file mode 100644 index 000000000..e0c6a1e48 --- /dev/null +++ b/services/auth-service/src/main/resources/templates/verifyEmailBody.html @@ -0,0 +1,90 @@ + + + + + + + Verify Your Account + + + +
+
Verify Your Account
+
+ Thank you for signing up! To complete your registration, please click the button below to verify your email + address. +
+ Verify Email Address + +
+ + \ No newline at end of file diff --git a/services/chat-service/pom.xml b/services/chat-service/pom.xml new file mode 100644 index 000000000..0de4332c2 --- /dev/null +++ b/services/chat-service/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + com.danielagapov + spawn-chat-service + 0.0.1-SNAPSHOT + spawn-chat-service + Spawn App Chat Microservice + + + 17 + 1.18.42 + 2023.0.4 + + + + + + org.springframework.cloud + spring-cloud-dependencies + ${spring-cloud.version} + pom + import + + + + + + + com.danielagapov + spawn-common + 0.0.1-SNAPSHOT + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-data-redis + + + org.springframework.boot + spring-boot-starter-actuator + + + + org.springframework.cloud + spring-cloud-starter-openfeign + + + org.springframework.cloud + spring-cloud-starter-circuitbreaker-resilience4j + + + + com.mysql + mysql-connector-j + runtime + + + com.h2database + h2 + runtime + + + + org.projectlombok + lombok + provided + + + + io.github.cdimascio + dotenv-java + 3.0.2 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/ChatServiceApplication.java b/services/chat-service/src/main/java/com/danielagapov/spawn/ChatServiceApplication.java new file mode 100644 index 000000000..b820d9e1c --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/ChatServiceApplication.java @@ -0,0 +1,41 @@ +package com.danielagapov.spawn; + +import io.github.cdimascio.dotenv.Dotenv; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.openfeign.EnableFeignClients; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@SpringBootApplication +@EnableJpaRepositories +@EnableFeignClients +public class ChatServiceApplication { + + public static void main(String[] args) { + String activeProfile = System.getProperty("spring.profiles.active", System.getenv("SPRING_PROFILES_ACTIVE")); + boolean isTestProfile = "test".equals(activeProfile); + + if (!isTestProfile) { + Dotenv dotenv = Dotenv.configure().ignoreIfMissing().load(); + loadEnvVar(dotenv, "MYSQL_URL"); + loadEnvVar(dotenv, "MYSQL_USER"); + loadEnvVar(dotenv, "MYSQL_PASSWORD"); + loadEnvVar(dotenv, "REDIS_HOST"); + loadEnvVar(dotenv, "REDIS_PORT"); + loadEnvVar(dotenv, "REDIS_PASSWORD"); + loadEnvVar(dotenv, "MONOLITH_URL"); + } + + SpringApplication.run(ChatServiceApplication.class, args); + } + + private static void loadEnvVar(Dotenv dotenv, String key) { + try { + String value = System.getenv(key) != null ? System.getenv(key) : dotenv.get(key); + if (value != null) { + System.setProperty(key, value); + } + } catch (NullPointerException ignored) { + } + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java new file mode 100644 index 000000000..46716fdba --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java @@ -0,0 +1,124 @@ +package com.danielagapov.spawn.chat.api; + +import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; +import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; +import com.danielagapov.spawn.chat.api.dto.CreateChatMessageDTO; +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import com.danielagapov.spawn.chat.internal.services.ChatMessageService; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("api/v1/chat-messages") +public class ChatMessageController { + + private final ChatMessageService chatMessageService; + private final ILogger logger; + + public ChatMessageController(ChatMessageService chatMessageService, ILogger logger) { + this.chatMessageService = chatMessageService; + this.logger = logger; + } + + @GetMapping("/by-activity/{activityId}") + public ResponseEntity> getChatMessagesByActivity(@PathVariable UUID activityId) { + try { + List messages = chatMessageService.getFullChatMessagesByActivityId(activityId); + return ResponseEntity.ok(messages); + } catch (Exception e) { + logger.error("Error getting chat messages for activity: " + activityId + ": " + e.getMessage()); + return ResponseEntity.ok(new ArrayList<>()); + } + } + + @GetMapping("/{id}") + public ResponseEntity getChatMessageById(@PathVariable UUID id) { + try { + return ResponseEntity.ok(chatMessageService.getChatMessageById(id)); + } catch (BaseNotFoundException e) { + return ResponseEntity.notFound().build(); + } + } + + @PostMapping + public ResponseEntity createChatMessage(@RequestBody CreateChatMessageDTO newChatMessage) { + try { + FullActivityChatMessageDTO created = chatMessageService.createChatMessage(newChatMessage); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } catch (Exception e) { + logger.error("Error creating chat message: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteChatMessage(@PathVariable UUID id) { + if (id == null) { + return ResponseEntity.badRequest().build(); + } + try { + boolean deleted = chatMessageService.deleteChatMessageById(id); + return deleted ? ResponseEntity.noContent().build() : ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } catch (BaseNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (Exception e) { + logger.error("Error deleting chat message: " + id + ": " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @PostMapping("/{chatMessageId}/likes/{userId}") + public ResponseEntity createChatMessageLike(@PathVariable UUID chatMessageId, @PathVariable UUID userId) { + if (chatMessageId == null || userId == null) { + return ResponseEntity.badRequest().build(); + } + try { + ChatMessageLikesDTO created = chatMessageService.createChatMessageLike(chatMessageId, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(created); + } catch (BaseNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (Exception e) { + logger.error("Error creating like: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @GetMapping("/{chatMessageId}/likes") + public ResponseEntity> getChatMessageLikes(@PathVariable UUID chatMessageId) { + if (chatMessageId == null) { + return ResponseEntity.badRequest().build(); + } + try { + return ResponseEntity.ok(chatMessageService.getChatMessageLikes(chatMessageId)); + } catch (BaseNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (Exception e) { + logger.error("Error getting likes: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } + + @DeleteMapping("/{chatMessageId}/likes/{userId}") + public ResponseEntity deleteChatMessageLike(@PathVariable UUID chatMessageId, @PathVariable UUID userId) { + if (chatMessageId == null || userId == null) { + return ResponseEntity.badRequest().build(); + } + try { + chatMessageService.deleteChatMessageLike(chatMessageId, userId); + return ResponseEntity.noContent().build(); + } catch (BaseNotFoundException e) { + return ResponseEntity.notFound().build(); + } catch (Exception e) { + logger.error("Error deleting like: " + e.getMessage()); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ActivityRef.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ActivityRef.java new file mode 100644 index 000000000..1341a7915 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ActivityRef.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.chat.internal.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * Minimal reference to the shared activity table for JPA FK from chat_message. + * Chat-service uses shared DB and does not own activity data. + */ +@Entity +@Table(name = "activity") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class ActivityRef { + @Id + private UUID id; +} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessage.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessage.java similarity index 58% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessage.java rename to services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessage.java index 7bb3a3a75..cf22a729c 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessage.java +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessage.java @@ -1,6 +1,5 @@ -package com.danielagapov.spawn.activity.internal.domain; +package com.danielagapov.spawn.chat.internal.domain; -import com.danielagapov.spawn.user.internal.domain.User; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -13,13 +12,6 @@ import java.time.Instant; import java.util.UUID; -/** - * A Chat Message is left under a specific Activity, and there may be - * many chat messages left by one person under the same Activity, to - * allow for a conversation or group chat of sorts to happen. - * We track the timestamp to display the delta time from when - * it was sent (e.g. 3 sec ago). - */ @Entity @NoArgsConstructor @AllArgsConstructor @@ -31,18 +23,17 @@ public class ChatMessage implements Serializable { private UUID id; @Column(length = 1000) - private String content; // Can be null or empty + private String content; private Instant timestamp; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) - private User userSender; + private UserRef userSender; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "activity_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) - private Activity activity; + private ActivityRef activity; } - diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikes.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikes.java similarity index 57% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikes.java rename to services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikes.java index 50671c849..69500fc4c 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikes.java +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikes.java @@ -1,6 +1,5 @@ -package com.danielagapov.spawn.activity.internal.domain; +package com.danielagapov.spawn.chat.internal.domain; -import com.danielagapov.spawn.user.internal.domain.User; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Getter; @@ -11,15 +10,6 @@ import java.io.Serializable; -/** - * A chat message like is left by someone onto a - * chat message (see documentation in `ChatMessage.java`). - * These should behave like toggles, to where one user - * can either like a message or not, so it's a 1-to-1 relationship. - * Also, it seems most apps like Instagram, Slack, discord allow you - * to react to/like your own message, so we'll leave that open - * for our own users as well. - */ @Entity @Table(name = "chat_message_like") @NoArgsConstructor @@ -30,16 +20,15 @@ public class ChatMessageLikes implements Serializable { @EmbeddedId private ChatMessageLikesId id; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @MapsId("chatMessageId") @JoinColumn(name = "chat_message_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) private ChatMessage chatMessage; - @ManyToOne + @ManyToOne(fetch = FetchType.LAZY) @MapsId("userId") @JoinColumn(name = "user_id", nullable = false) @OnDelete(action = OnDeleteAction.CASCADE) - private User user; + private UserRef user; } - diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikesId.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikesId.java similarity index 88% rename from src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikesId.java rename to services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikesId.java index 6a9ad17bd..8e09678bf 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/domain/ChatMessageLikesId.java +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/ChatMessageLikesId.java @@ -1,4 +1,4 @@ -package com.danielagapov.spawn.activity.internal.domain; +package com.danielagapov.spawn.chat.internal.domain; import jakarta.persistence.Column; import jakarta.persistence.Embeddable; @@ -27,8 +27,7 @@ public class ChatMessageLikesId implements Serializable { public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ChatMessageLikesId that)) return false; - return Objects.equals(chatMessageId, that.chatMessageId) && - Objects.equals(userId, that.userId); + return Objects.equals(chatMessageId, that.chatMessageId) && Objects.equals(userId, that.userId); } @Override @@ -36,4 +35,3 @@ public int hashCode() { return Objects.hash(chatMessageId, userId); } } - diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/UserRef.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/UserRef.java new file mode 100644 index 000000000..bc53228b7 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/domain/UserRef.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.chat.internal.domain; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * Minimal reference to the shared user table for JPA FK from chat_message. + * Chat-service uses shared DB and does not own user data. + */ +@Entity +@Table(name = "`user`") +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class UserRef { + @Id + private UUID id; +} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageLikesRepository.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageLikesRepository.java similarity index 64% rename from src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageLikesRepository.java rename to services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageLikesRepository.java index 7c3b0ae95..629d0c8b7 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageLikesRepository.java +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageLikesRepository.java @@ -1,8 +1,8 @@ -package com.danielagapov.spawn.activity.internal.repositories; +package com.danielagapov.spawn.chat.internal.repositories; -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikes; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikesId; +import com.danielagapov.spawn.chat.internal.domain.ChatMessage; +import com.danielagapov.spawn.chat.internal.domain.ChatMessageLikes; +import com.danielagapov.spawn.chat.internal.domain.ChatMessageLikesId; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -11,8 +11,10 @@ @Repository public interface IChatMessageLikesRepository extends JpaRepository { + boolean existsByChatMessage_IdAndUser_Id(UUID chatMessageId, UUID userId); + void deleteByChatMessage_IdAndUser_Id(UUID chatMessageId, UUID userId); + List findByChatMessage(ChatMessage chatMessage); } - diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageRepository.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageRepository.java new file mode 100644 index 000000000..d4e0b5e9c --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/repositories/IChatMessageRepository.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.chat.internal.repositories; + +import com.danielagapov.spawn.chat.internal.domain.ChatMessage; +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; +import java.util.UUID; + +@Repository +public interface IChatMessageRepository extends JpaRepository { + + @Query("SELECT cm FROM ChatMessage cm WHERE cm.activity.id = :activityId ORDER BY cm.timestamp DESC") + List getChatMessagesByActivityIdOrderByTimestampDesc(@Param("activityId") UUID activityId); + + @Query("SELECT cm.activity.id, cm.id FROM ChatMessage cm WHERE cm.activity.id IN :activityIds ORDER BY cm.activity.id, cm.timestamp DESC") + List findChatMessageIdsByActivityIds(@Param("activityIds") List activityIds); +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java new file mode 100644 index 000000000..c2c26b26a --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java @@ -0,0 +1,184 @@ +package com.danielagapov.spawn.chat.internal.services; + +import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; +import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; +import com.danielagapov.spawn.chat.api.dto.CreateChatMessageDTO; +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import com.danielagapov.spawn.chat.internal.domain.ChatMessage; +import com.danielagapov.spawn.chat.internal.domain.ChatMessageLikes; +import com.danielagapov.spawn.chat.internal.domain.ChatMessageLikesId; +import com.danielagapov.spawn.chat.internal.repositories.IChatMessageLikesRepository; +import com.danielagapov.spawn.chat.internal.repositories.IChatMessageRepository; +import com.danielagapov.spawn.chat.internal.util.ChatMessageMapper; +import com.danielagapov.spawn.shared.events.redis.NewCommentRedisEvent; +import com.danielagapov.spawn.shared.events.redis.RedisEventChannels; +import com.danielagapov.spawn.shared.events.redis.RedisEventPublisher; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; +import com.danielagapov.spawn.shared.exceptions.EntityAlreadyExistsException; +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.shared.feign.MonolithUserClient; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +@Service +public class ChatMessageService { + + private final IChatMessageRepository chatMessageRepository; + private final IChatMessageLikesRepository chatMessageLikesRepository; + private final MonolithUserClient monolithUserClient; + private final RedisEventPublisher redisEventPublisher; + private final ILogger logger; + + public ChatMessageService(IChatMessageRepository chatMessageRepository, + IChatMessageLikesRepository chatMessageLikesRepository, + MonolithUserClient monolithUserClient, + RedisEventPublisher redisEventPublisher, + ILogger logger) { + this.chatMessageRepository = chatMessageRepository; + this.chatMessageLikesRepository = chatMessageLikesRepository; + this.monolithUserClient = monolithUserClient; + this.redisEventPublisher = redisEventPublisher; + this.logger = logger; + } + + public ChatMessageDTO getChatMessageById(UUID id) { + ChatMessage cm = chatMessageRepository.findById(id) + .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, id)); + List likedBy = getChatMessageLikeUserIds(cm.getId()); + return ChatMessageMapper.toDTO(cm, likedBy); + } + + public List getFullChatMessagesByActivityId(UUID activityId) { + List list = getChatMessagesByActivityId(activityId); + List result = new ArrayList<>(); + for (ChatMessageDTO cm : list) { + result.add(getFullChatMessageByChatMessage(cm)); + } + return result; + } + + @Transactional + public FullActivityChatMessageDTO createChatMessage(CreateChatMessageDTO dto) { + ChatMessageDTO toSave = new ChatMessageDTO(null, dto.getContent(), Instant.now(), + dto.getSenderUserId(), dto.getActivityId(), List.of()); + ChatMessageDTO saved = saveChatMessage(toSave); + + String senderUsername; + try { + BaseUserDTO sender = monolithUserClient.getUserById(dto.getSenderUserId()); + senderUsername = sender != null && sender.getUsername() != null ? sender.getUsername() : "unknown"; + } catch (Exception e) { + logger.warn("Could not fetch sender username for new-comment event: " + e.getMessage()); + senderUsername = "unknown"; + } + + redisEventPublisher.publish(RedisEventChannels.NEW_COMMENT, new NewCommentRedisEvent( + dto.getSenderUserId(), + senderUsername, + dto.getActivityId(), + saved.getId(), + saved.getContent() + )); + + return getFullChatMessageByChatMessage(saved); + } + + public ChatMessageDTO saveChatMessage(ChatMessageDTO dto) { + ChatMessage entity = ChatMessageMapper.toEntity(dto, dto.getSenderUserId(), dto.getActivityId()); + ChatMessage saved = chatMessageRepository.save(entity); + return ChatMessageMapper.toDTO(saved, List.of()); + } + + public List getChatMessageIdsByActivityId(UUID activityId) { + return chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(activityId) + .stream().map(ChatMessage::getId).collect(Collectors.toList()); + } + + public List getChatMessagesByActivityId(UUID activityId) { + List messages = chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(activityId); + return messages.stream() + .map(cm -> ChatMessageMapper.toDTO(cm, getChatMessageLikeUserIds(cm.getId()))) + .collect(Collectors.toList()); + } + + public boolean deleteChatMessageById(UUID id) { + if (!chatMessageRepository.existsById(id)) { + throw new BaseNotFoundException(EntityType.ChatMessage, id); + } + chatMessageRepository.deleteById(id); + return true; + } + + public ChatMessageLikesDTO createChatMessageLike(UUID chatMessageId, UUID userId) { + if (chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)) { + throw new EntityAlreadyExistsException(EntityType.ChatMessage, chatMessageId); + } + ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) + .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); + ChatMessageLikesId id = new ChatMessageLikesId(chatMessageId, userId); + ChatMessageLikes like = new ChatMessageLikes(); + like.setId(id); + like.setChatMessage(chatMessage); + like.setUser(new com.danielagapov.spawn.chat.internal.domain.UserRef(userId)); + chatMessageLikesRepository.save(like); + return new ChatMessageLikesDTO(chatMessageId, userId); + } + + public List getChatMessageLikes(UUID chatMessageId) { + ChatMessage cm = chatMessageRepository.findById(chatMessageId) + .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); + List likes = chatMessageLikesRepository.findByChatMessage(cm); + List result = new ArrayList<>(); + for (ChatMessageLikes like : likes) { + try { + result.add(monolithUserClient.getUserById(like.getUser().getId())); + } catch (Exception e) { + logger.warn("Could not fetch user " + like.getUser().getId() + " for like: " + e.getMessage()); + } + } + return result; + } + + public void deleteChatMessageLike(UUID chatMessageId, UUID userId) { + if (!chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)) { + throw new BaseNotFoundException(EntityType.ChatMessage); + } + chatMessageLikesRepository.deleteByChatMessage_IdAndUser_Id(chatMessageId, userId); + } + + private List getChatMessageLikeUserIds(UUID chatMessageId) { + ChatMessage cm = chatMessageRepository.findById(chatMessageId) + .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); + return chatMessageLikesRepository.findByChatMessage(cm).stream() + .map(l -> l.getUser().getId()) + .collect(Collectors.toList()); + } + + private FullActivityChatMessageDTO getFullChatMessageByChatMessage(ChatMessageDTO chatMessage) { + BaseUserDTO sender; + try { + sender = monolithUserClient.getUserById(chatMessage.getSenderUserId()); + } catch (Exception e) { + logger.warn("Could not fetch sender for chat message: " + e.getMessage()); + throw new BaseSaveException("User lookup failed: " + e.getMessage()); + } + List likedByUsers = getChatMessageLikes(chatMessage.getId()); + return new FullActivityChatMessageDTO( + chatMessage.getId(), + chatMessage.getContent(), + chatMessage.getTimestamp(), + sender, + chatMessage.getActivityId(), + likedByUsers + ); + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/util/ChatMessageMapper.java b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/util/ChatMessageMapper.java new file mode 100644 index 000000000..3733a1e51 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/chat/internal/util/ChatMessageMapper.java @@ -0,0 +1,36 @@ +package com.danielagapov.spawn.chat.internal.util; + +import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; +import com.danielagapov.spawn.chat.internal.domain.ActivityRef; +import com.danielagapov.spawn.chat.internal.domain.ChatMessage; +import com.danielagapov.spawn.chat.internal.domain.UserRef; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +public final class ChatMessageMapper { + + private ChatMessageMapper() {} + + public static ChatMessageDTO toDTO(ChatMessage entity, List likedByUserIds) { + return new ChatMessageDTO( + entity.getId(), + entity.getContent(), + entity.getTimestamp(), + entity.getUserSender().getId(), + entity.getActivity().getId(), + likedByUserIds + ); + } + + public static ChatMessage toEntity(ChatMessageDTO dto, UUID senderUserId, UUID activityId) { + return new ChatMessage( + dto.getId(), + dto.getContent(), + dto.getTimestamp(), + new UserRef(senderUserId), + new ActivityRef(activityId) + ); + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java new file mode 100644 index 000000000..998eba0b6 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/config/RedisConfig.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.shared.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.StringRedisTemplate; + +@Configuration +public class RedisConfig { + + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + return new StringRedisTemplate(connectionFactory); + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java new file mode 100644 index 000000000..d490b340c --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.shared.events.redis; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Event published to Redis when a new chat message is created. + * Monolith subscribes and builds NewCommentNotificationEvent (with activity title, participants from its own services). + */ +public record NewCommentRedisEvent( + UUID senderUserId, + String senderUsername, + UUID activityId, + UUID messageId, + String content +) implements Serializable {} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java new file mode 100644 index 000000000..201b2fe43 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.events.redis; + +public final class RedisEventChannels { + + private RedisEventChannels() {} + + /** Published by chat-service when a new comment is created. Monolith subscribes to send notifications. */ + public static final String NEW_COMMENT = "events:new-comment"; +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java new file mode 100644 index 000000000..480de0e80 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventPublisher.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; + +@Component +public class RedisEventPublisher { + + private static final Logger log = LoggerFactory.getLogger(RedisEventPublisher.class); + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + + public RedisEventPublisher(StringRedisTemplate redisTemplate) { + this.redisTemplate = redisTemplate; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + public void publish(String channel, Object event) { + try { + String json = objectMapper.writeValueAsString(event); + redisTemplate.convertAndSend(channel, json); + log.debug("Published event to channel '{}'", channel); + } catch (JsonProcessingException e) { + log.error("Failed to serialise event for channel '{}': {}", channel, e.getMessage()); + } catch (Exception e) { + log.error("Failed to publish event to channel '{}': {}", channel, e.getMessage()); + } + } +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java new file mode 100644 index 000000000..0b5682492 --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClient.java @@ -0,0 +1,19 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.UUID; + +@FeignClient( + name = "monolith-user-client", + url = "${services.monolith.url}", + fallbackFactory = MonolithUserClientFallbackFactory.class +) +public interface MonolithUserClient { + + @GetMapping("/api/v1/users/{id}") + BaseUserDTO getUserById(@PathVariable("id") UUID id); +} diff --git a/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java new file mode 100644 index 000000000..e1cfc8e9e --- /dev/null +++ b/services/chat-service/src/main/java/com/danielagapov/spawn/shared/feign/MonolithUserClientFallbackFactory.java @@ -0,0 +1,23 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.cloud.openfeign.FallbackFactory; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +public class MonolithUserClientFallbackFactory implements FallbackFactory { + + private static final Logger log = LoggerFactory.getLogger(MonolithUserClientFallbackFactory.class); + + @Override + public MonolithUserClient create(Throwable cause) { + return id -> { + log.warn("Fallback: monolith unreachable when fetching user {}. Cause: {}", id, cause.getMessage()); + throw new RuntimeException("User service unavailable", cause); + }; + } +} diff --git a/services/chat-service/src/main/resources/application.properties b/services/chat-service/src/main/resources/application.properties new file mode 100644 index 000000000..6623bc84b --- /dev/null +++ b/services/chat-service/src/main/resources/application.properties @@ -0,0 +1,31 @@ +spring.application.name=spawn-chat-service +server.port=8083 + +# Database (shared MySQL) +spring.datasource.url=${MYSQL_URL} +spring.datasource.username=${MYSQL_USER} +spring.datasource.password=${MYSQL_PASSWORD} +spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.show-sql=false +spring.jpa.open-in-view=false +spring.flyway.enabled=false + +# Redis +spring.data.redis.host=${REDIS_HOST:localhost} +spring.data.redis.port=${REDIS_PORT:6379} +spring.data.redis.password=${REDIS_PASSWORD:} + +# Monolith URL for Feign (user lookup) +services.monolith.url=${MONOLITH_URL:http://localhost:8080} + +# Resilience4j +resilience4j.circuitbreaker.instances.monolith.register-health-indicator=true +resilience4j.circuitbreaker.instances.monolith.sliding-window-size=10 +resilience4j.circuitbreaker.instances.monolith.failure-rate-threshold=50 +resilience4j.circuitbreaker.instances.monolith.wait-duration-in-open-state=30s +resilience4j.timelimiter.instances.monolith.timeout-duration=5s + +# Actuator +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always diff --git a/shared/spawn-common/pom.xml b/shared/spawn-common/pom.xml new file mode 100644 index 000000000..7bb56a5b1 --- /dev/null +++ b/shared/spawn-common/pom.xml @@ -0,0 +1,103 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + com.danielagapov + spawn-common + 0.0.1-SNAPSHOT + jar + spawn-common + Shared library for Spawn microservices - common DTOs, exceptions, validation, utilities + + 17 + 1.18.42 + + + + + org.springframework.boot + spring-boot-starter-web + provided + + + + + org.springframework.boot + spring-boot-starter-validation + provided + + + + + org.springframework.boot + spring-boot-starter-security + provided + + + + + org.projectlombok + lombok + provided + + + + + com.fasterxml.jackson.core + jackson-annotations + + + + + central + https://repo.maven.apache.org/maven2 + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + 17 + 17 + 17 + true + + + org.projectlombok + lombok + ${lombok.version} + + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar + + + + + + + diff --git a/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/AbstractActivityDTO.java diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java new file mode 100644 index 000000000..3497288d0 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityCreationResponseDTO.java @@ -0,0 +1,29 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Response DTO for activity creation that wraps the created activity + * and optionally includes friend suggestions for activity types. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ActivityCreationResponseDTO { + private FullFeedActivityDTO activity; + private ActivityTypeFriendSuggestionDTO friendSuggestion; + + /** + * Create a response with just the activity (no friend suggestion) + */ + public ActivityCreationResponseDTO(FullFeedActivityDTO activity) { + this.activity = activity; + this.friendSuggestion = null; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java new file mode 100644 index 000000000..188998df1 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityDTO.java @@ -0,0 +1,49 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@NoArgsConstructor +@Getter +@Setter +public class ActivityDTO extends AbstractActivityDTO { + LocationDTO location; + UUID activityTypeId; + UUID creatorUserId; + List participantUserIds; + List invitedUserIds; + List chatMessageIds; + String clientTimezone; // Timezone of the client creating the activity (e.g., "America/New_York") + + public ActivityDTO(UUID id, + String title, + OffsetDateTime startTime, + OffsetDateTime endTime, + LocationDTO location, + UUID activityTypeId, + String note, + String icon, + Integer participantLimit, + UUID creatorUserId, + List participantUserIds, + List invitedUserIds, + List chatMessageIds, + Instant createdAt, + boolean isExpired, + String clientTimezone) { + super(id, title, startTime, endTime, note, icon, participantLimit, createdAt, isExpired, clientTimezone); + this.location = location; + this.activityTypeId = activityTypeId; + this.creatorUserId = creatorUserId; + this.participantUserIds = participantUserIds; + this.invitedUserIds = invitedUserIds; + this.chatMessageIds = chatMessageIds; + this.clientTimezone = clientTimezone; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java new file mode 100644 index 000000000..e09cf4e9e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityInviteDTO.java @@ -0,0 +1,48 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * DTO for external activity invites - contains only essential information + * needed for the activity invite page without requiring authentication + */ +@NoArgsConstructor +@Getter +@Setter +public class ActivityInviteDTO extends AbstractActivityDTO { + UUID locationId; + UUID activityTypeId; + UUID creatorUserId; + List participantUserIds; + List invitedUserIds; + + public ActivityInviteDTO(UUID id, + String title, + OffsetDateTime startTime, + OffsetDateTime endTime, + UUID locationId, + UUID activityTypeId, + String note, + String icon, + Integer participantLimit, + UUID creatorUserId, + List participantUserIds, + List invitedUserIds, + Instant createdAt, + boolean isExpired, + String clientTimezone) { + super(id, title, startTime, endTime, note, icon, participantLimit, createdAt, isExpired, clientTimezone); + this.locationId = locationId; + this.activityTypeId = activityTypeId; + this.creatorUserId = creatorUserId; + this.participantUserIds = participantUserIds; + this.invitedUserIds = invitedUserIds; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java new file mode 100644 index 000000000..aa9430bae --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityPartialUpdateDTO.java @@ -0,0 +1,99 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.NoArgsConstructor; + +/** + * DTO for partial activity updates using PATCH requests. + * This DTO allows for updating specific fields of an activity without requiring all fields. + * All fields are optional and only non-null values will be processed. + */ +@NoArgsConstructor +public class ActivityPartialUpdateDTO { + + private String title; + private String icon; + + @JsonProperty("startTime") + private String startTime; // ISO8601 formatted string + + @JsonProperty("endTime") + private String endTime; // ISO8601 formatted string + + @JsonProperty("participantLimit") + private Integer participantLimit; + + private String note; + + // Constructor with all fields + public ActivityPartialUpdateDTO(String title, String icon, String startTime, String endTime, + Integer participantLimit, String note) { + this.title = title; + this.icon = icon; + this.startTime = startTime; + this.endTime = endTime; + this.participantLimit = participantLimit; + this.note = note; + } + + // Getters and Setters + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getStartTime() { + return startTime; + } + + public void setStartTime(String startTime) { + this.startTime = startTime; + } + + public String getEndTime() { + return endTime; + } + + public void setEndTime(String endTime) { + this.endTime = endTime; + } + + public Integer getParticipantLimit() { + return participantLimit; + } + + public void setParticipantLimit(Integer participantLimit) { + this.participantLimit = participantLimit; + } + + public String getNote() { + return note; + } + + public void setNote(String note) { + this.note = note; + } + + @Override + public String toString() { + return "ActivityPartialUpdateDTO{" + + "title='" + title + '\'' + + ", icon='" + icon + '\'' + + ", startTime='" + startTime + '\'' + + ", endTime='" + endTime + '\'' + + ", participantLimit=" + participantLimit + + ", note='" + note + '\'' + + '}'; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java new file mode 100644 index 000000000..174206cef --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityParticipationDTO.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.danielagapov.spawn.shared.util.ParticipationStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ActivityParticipationDTO implements Serializable { + UUID activityId; + UUID userId; + ParticipationStatus status; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java new file mode 100644 index 000000000..73108104c --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeDTO.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.danielagapov.spawn.user.api.dto.FriendUser.MinimalFriendDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.List; +import java.util.UUID; + +/** + * DTO for activity types. + * + * Note: associatedFriends uses MinimalFriendDTO instead of BaseUserDTO to reduce memory usage. + * MinimalFriendDTO only contains essential fields (id, username, name, profilePicture) + * needed for displaying friends in activity type selection UI. + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ActivityTypeDTO implements Serializable { + private UUID id; + private String title; + private List associatedFriends; + private String icon; + private int orderNum; + private UUID ownerUserId; + private Boolean isPinned; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java new file mode 100644 index 000000000..b2acd4452 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ActivityTypeFriendSuggestionDTO.java @@ -0,0 +1,28 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ActivityTypeFriendSuggestionDTO { + private UUID activityTypeId; + private String activityTypeTitle; + private List suggestedFriends; + private boolean shouldShowPrompt; + + public ActivityTypeFriendSuggestionDTO(UUID activityTypeId, String activityTypeTitle, List suggestedFriends) { + this.activityTypeId = activityTypeId; + this.activityTypeTitle = activityTypeTitle; + this.suggestedFriends = suggestedFriends; + this.shouldShowPrompt = suggestedFriends != null && !suggestedFriends.isEmpty(); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java new file mode 100644 index 000000000..0f8f14b4a --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/BatchActivityTypeUpdateDTO.java @@ -0,0 +1,19 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class BatchActivityTypeUpdateDTO implements Serializable { + private List updatedActivityTypes; // List of updated/newly created Activity Types + private List deletedActivityTypeIds; // List of Activity Type IDs that were deleted +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java new file mode 100644 index 000000000..f365887c6 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/CalendarActivityDTO.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.*; + +import java.io.Serializable; +import java.util.UUID; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Getter +public class CalendarActivityDTO implements Serializable { + private UUID id; + private String date; + private String title; + private String icon; + private String colorHexCode; + private UUID activityId; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java new file mode 100644 index 000000000..957706764 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/FullFeedActivityDTO.java @@ -0,0 +1,63 @@ +package com.danielagapov.spawn.activity.api.dto; + + +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.danielagapov.spawn.shared.util.ParticipationStatus; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +@NoArgsConstructor +@Getter +@Setter +public class FullFeedActivityDTO extends AbstractActivityDTO { + private LocationDTO location; + private UUID activityTypeId; + private BaseUserDTO creatorUser; + private List participantUsers; + private List invitedUsers; + private List chatMessages; + + // ensures string formatting when serialized to JSON; for mobile (client) + private @JsonFormat(shape = JsonFormat.Shape.STRING) ParticipationStatus participationStatus; + @JsonProperty("isSelfOwned") // specifying JSON name, + // since booleans get turned to `selfOwned` (remove `is` from name) + private boolean isSelfOwned; + + public FullFeedActivityDTO(UUID id, + String title, + OffsetDateTime startTime, + OffsetDateTime endTime, + LocationDTO location, + UUID activityTypeId, + String note, + String icon, + Integer participantLimit, + BaseUserDTO creatorUser, + List participantUsers, + List invitedUsers, + List chatMessages, + ParticipationStatus participationStatus, + boolean isSelfOwned, + Instant createdAt, + boolean isExpired, + String clientTimezone) { + super(id, title, startTime, endTime, note, icon, participantLimit, createdAt, isExpired, clientTimezone); + this.location = location; + this.activityTypeId = activityTypeId; + this.creatorUser = creatorUser; + this.participantUsers = participantUsers; + this.invitedUsers = invitedUsers; + this.chatMessages = chatMessages; + this.participationStatus = participationStatus; + this.isSelfOwned = isSelfOwned; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java new file mode 100644 index 000000000..cf04ddc31 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/LocationDTO.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@NoArgsConstructor +@AllArgsConstructor +@Getter +@Setter +public class LocationDTO implements Serializable { + UUID id; + String name; + double latitude; + double longitude; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java new file mode 100644 index 000000000..9f2ac0f61 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/ProfileActivityDTO.java @@ -0,0 +1,92 @@ +package com.danielagapov.spawn.activity.api.dto; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * DTO specifically for profile view activities that includes whether the activity is past or upcoming + */ +@NoArgsConstructor +@Getter +@Setter +public class ProfileActivityDTO extends AbstractActivityDTO { + private LocationDTO location; + private BaseUserDTO creatorUser; + private List participantUsers; + private List invitedUsers; + private List chatMessageIds; + + /** + * Indicates whether this activity is in the past. + * Note: @JsonProperty is required because Lombok generates isPastActivity() getter, + * which Jackson would serialize as "pastActivity" without this annotation. + */ + @JsonProperty("isPastActivity") + private boolean isPastActivity; + + public ProfileActivityDTO(UUID id, + String title, + OffsetDateTime startTime, + OffsetDateTime endTime, + LocationDTO location, + String note, + String icon, + Integer participantLimit, + BaseUserDTO creatorUser, + List participantUsers, + List invitedUsers, + List chatMessageIds, + Instant createdAt, + boolean isExpired, + String clientTimezone, + boolean isPastActivity) { + super(id, title, startTime, endTime, note, icon, participantLimit, createdAt, isExpired, clientTimezone); + this.location = location; + this.creatorUser = creatorUser; + this.participantUsers = participantUsers; + this.invitedUsers = invitedUsers; + this.chatMessageIds = chatMessageIds; + this.isPastActivity = isPastActivity; + } + + /** + * Creates a ProfileActivityDTO from a FullFeedActivityDTO + * + * @param fullFeedActivityDTO The FullFeedActivityDTO to convert + * @param isPastActivity Whether this activity is in the past + * @return A new ProfileActivityDTO + */ + public static ProfileActivityDTO fromFullFeedActivityDTO(FullFeedActivityDTO fullFeedActivityDTO, boolean isPastActivity) { + // Convert chat messages to their IDs + List chatMessageIds = fullFeedActivityDTO.getChatMessages() != null ? + fullFeedActivityDTO.getChatMessages().stream().map(msg -> msg.getId()).collect(java.util.stream.Collectors.toList()) : + null; + + return new ProfileActivityDTO( + fullFeedActivityDTO.getId(), + fullFeedActivityDTO.getTitle(), + fullFeedActivityDTO.getStartTime(), + fullFeedActivityDTO.getEndTime(), + fullFeedActivityDTO.getLocation(), + fullFeedActivityDTO.getNote(), + fullFeedActivityDTO.getIcon(), + fullFeedActivityDTO.getParticipantLimit(), + fullFeedActivityDTO.getCreatorUser(), + fullFeedActivityDTO.getParticipantUsers(), + fullFeedActivityDTO.getInvitedUsers(), + chatMessageIds, + fullFeedActivityDTO.getCreatedAt(), + fullFeedActivityDTO.isExpired(), + fullFeedActivityDTO.getClientTimezone(), + isPastActivity + ); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java new file mode 100644 index 000000000..578a2390b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/activity/api/dto/UserIdActivityTimeDTO.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.activity.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class UserIdActivityTimeDTO { + private UUID userId; + private OffsetDateTime startTime; +} diff --git a/src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java similarity index 75% rename from src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java index 61389ddee..fa95affb5 100644 --- a/src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/AbstractChatMessageDTO.java @@ -13,10 +13,9 @@ @AllArgsConstructor @Getter @Setter - -public abstract class AbstractChatMessageDTO implements Serializable{ - UUID id; - String content; - Instant timestamp; - UUID activityId; +public abstract class AbstractChatMessageDTO implements Serializable { + private UUID id; + private String content; + private Instant timestamp; + private UUID activityId; } diff --git a/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java similarity index 79% rename from src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java index 70a4cc2de..5987ab6b7 100644 --- a/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageDTO.java @@ -11,12 +11,13 @@ @NoArgsConstructor @Getter @Setter -public class ChatMessageDTO extends AbstractChatMessageDTO{ - UUID senderUserId; - List likedByUserIds; +public class ChatMessageDTO extends AbstractChatMessageDTO { + private UUID senderUserId; + private List likedByUserIds; + public ChatMessageDTO(UUID id, String content, Instant timestamp, UUID senderUserId, UUID activityId, List likedByUserIds) { super(id, content, timestamp, activityId); this.senderUserId = senderUserId; this.likedByUserIds = likedByUserIds; } -} \ No newline at end of file +} diff --git a/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java similarity index 56% rename from src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java index 181ff041c..a9e0697e5 100644 --- a/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/ChatMessageLikesDTO.java @@ -2,15 +2,17 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.io.Serializable; import java.util.UUID; +@NoArgsConstructor @AllArgsConstructor @Getter @Setter -public class ChatMessageLikesDTO implements Serializable{ - UUID chatMessageId; - UUID userId; +public class ChatMessageLikesDTO implements Serializable { + private UUID chatMessageId; + private UUID userId; } diff --git a/src/main/java/com/danielagapov/spawn/chat/api/dto/CreateChatMessageDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/CreateChatMessageDTO.java similarity index 100% rename from src/main/java/com/danielagapov/spawn/chat/api/dto/CreateChatMessageDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/CreateChatMessageDTO.java diff --git a/src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java similarity index 73% rename from src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java rename to shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java index a5fe988a4..26189160d 100644 --- a/src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/chat/api/dto/FullActivityChatMessageDTO.java @@ -13,12 +13,12 @@ @Getter @Setter public class FullActivityChatMessageDTO extends AbstractChatMessageDTO { - BaseUserDTO senderUser; - List likedByUsers; + private BaseUserDTO senderUser; + private List likedByUsers; - public FullActivityChatMessageDTO(UUID id, String content, Instant timestamp, BaseUserDTO senderUser, UUID ActivityId, List likedByUsers) { - super(id, content, timestamp, ActivityId); + public FullActivityChatMessageDTO(UUID id, String content, Instant timestamp, BaseUserDTO senderUser, UUID activityId, List likedByUsers) { + super(id, content, timestamp, activityId); this.senderUser = senderUser; this.likedByUsers = likedByUsers; } -} \ No newline at end of file +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java new file mode 100644 index 000000000..5021a09e4 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.UserField; + +public class AccountAlreadyExistsException extends FieldAlreadyExistsException { + public AccountAlreadyExistsException(String message) { + super(message, UserField.EXTERNAL_ID); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java new file mode 100644 index 000000000..28b338f5b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/AccountNotFoundException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class AccountNotFoundException extends RuntimeException { + public AccountNotFoundException(String message) { + super(message); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java new file mode 100644 index 000000000..9a56e9d80 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityFullException.java @@ -0,0 +1,22 @@ +package com.danielagapov.spawn.shared.exceptions; + +import java.util.UUID; + +public class ActivityFullException extends RuntimeException { + private final UUID activityId; + private final Integer participantLimit; + + public ActivityFullException(UUID activityId, Integer participantLimit) { + super(String.format("Activity %s is full. Maximum participants: %d", activityId, participantLimit)); + this.activityId = activityId; + this.participantLimit = participantLimit; + } + + public UUID getActivityId() { + return activityId; + } + + public Integer getParticipantLimit() { + return participantLimit; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java new file mode 100644 index 000000000..e5db8d721 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityNotFoundException.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.exceptions; + +import java.util.UUID; + +import com.danielagapov.spawn.shared.util.EntityType; +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; + +public class ActivityNotFoundException extends BaseNotFoundException{ + public ActivityNotFoundException(UUID id) { + super(EntityType.Activity, id); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java new file mode 100644 index 000000000..fd40b897f --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ActivityTypeValidationException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.exceptions.ApplicationException; + +public class ActivityTypeValidationException extends ApplicationException { + public ActivityTypeValidationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java new file mode 100644 index 000000000..689eb7789 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/ApplicationException.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class ApplicationException extends RuntimeException { + public ApplicationException(String message, Throwable cause) { + super(message, cause); + } + public ApplicationException(String message) {super(message);} +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java new file mode 100644 index 000000000..eb3f5a5dd --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseDeleteException.java @@ -0,0 +1,10 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +public class BaseDeleteException extends RuntimeException { + public BaseDeleteException(String message) { + super("Could not delete entity: " + message); + } + public BaseDeleteException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java new file mode 100644 index 000000000..12438393c --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseExceptionHandler.java @@ -0,0 +1,26 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class BaseExceptionHandler { + + @ExceptionHandler(BaseNotFoundException.class) + public ResponseEntity handleBaseNotFoundException(BaseNotFoundException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BasesNotFoundException.class) + public ResponseEntity handleBasesNotFoundException(BasesNotFoundException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.NOT_FOUND); + } + + @ExceptionHandler(BaseSaveException.class) + public ResponseEntity handleBaseSaveException(BaseSaveException ex) { + return new ResponseEntity<>(ex.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); + } +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java new file mode 100644 index 000000000..80028e381 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseNotFoundException.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import com.danielagapov.spawn.shared.util.EntityType; + +import java.util.UUID; + +public class BaseNotFoundException extends RuntimeException { + public final EntityType entityType; + + public BaseNotFoundException(EntityType et) { + super(et + " entity not found"); + this.entityType = et; + } + + public BaseNotFoundException(EntityType et, UUID id) { + super(et + " entity not found with ID: " + id); + this.entityType = et; + } + + /** + * + * @param et The entity type that could not be found. + * @param identifier this could be something like a user's email or username + * @param identifierType to indicate if it's an email, username, or something else in the print statement. + */ + public BaseNotFoundException(EntityType et, String identifier, String identifierType) { + super(et + " entity not found with " + identifierType + ": " + identifier); + this.entityType = et; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java new file mode 100644 index 000000000..cc2208c7f --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BaseSaveException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +public class BaseSaveException extends RuntimeException { + public BaseSaveException(String message) { + super("failed to save an entity: " + message); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java new file mode 100644 index 000000000..59a4ef440 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Base/BasesNotFoundException.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions.Base; + +import com.danielagapov.spawn.shared.util.EntityType; + +public class BasesNotFoundException extends RuntimeException { + public final EntityType entityType; + public BasesNotFoundException(EntityType type) { + super(type + "'s not found."); + this.entityType = type; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java new file mode 100644 index 000000000..a3da2be96 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/DatabaseException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class DatabaseException extends RuntimeException { + public DatabaseException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java new file mode 100644 index 000000000..01e45bdf8 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.EMAIL; + +public class EmailAlreadyExistsException extends FieldAlreadyExistsException { + public EmailAlreadyExistsException(String message) { + super(message, EMAIL); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java new file mode 100644 index 000000000..99a63edee --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EmailVerificationException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class EmailVerificationException extends RuntimeException { + public EmailVerificationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java new file mode 100644 index 000000000..4ca5cbde8 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/EntityAlreadyExistsException.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.EntityType; + +import java.util.UUID; + +public class EntityAlreadyExistsException extends RuntimeException { + public EntityAlreadyExistsException(EntityType entityType, UUID id) { + super("Entity already exists for type, " + entityType.getDescription() + " with UUID: " + id); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java new file mode 100644 index 000000000..d4d8db100 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/FieldAlreadyExistsException.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.util.UserField; + +public class FieldAlreadyExistsException extends RuntimeException { + protected final UserField field; + + public FieldAlreadyExistsException(String message, UserField field) { + super(message); + this.field = field; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java new file mode 100644 index 000000000..0160b702b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/GlobalExceptionHandler.java @@ -0,0 +1,192 @@ +package com.danielagapov.spawn.shared.exceptions; + +import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; +import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; +import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.context.request.WebRequest; + +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @Autowired + private ILogger logger; + + /** + * Handle authentication-related exceptions + */ + @ExceptionHandler({BadCredentialsException.class, UsernameNotFoundException.class}) + public ResponseEntity> handleAuthenticationException(Exception ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + // Log the full error internally with unique ID + logger.error("Authentication error [" + errorId + "]: " + ex.getMessage()); + + // Return generic message to prevent user enumeration + Map response = createErrorResponse( + "AUTHENTICATION_FAILED", + "Invalid credentials", + HttpStatus.UNAUTHORIZED, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.UNAUTHORIZED); + } + + /** + * Handle entity not found exceptions + */ + @ExceptionHandler(BaseNotFoundException.class) + public ResponseEntity> handleBaseNotFoundException(BaseNotFoundException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.warn("Entity not found [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "RESOURCE_NOT_FOUND", + "The requested resource was not found", + HttpStatus.NOT_FOUND, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.NOT_FOUND); + } + + /** + * Handle save operation exceptions + */ + @ExceptionHandler(BaseSaveException.class) + public ResponseEntity> handleBaseSaveException(BaseSaveException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.error("Save operation failed [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "OPERATION_FAILED", + "Operation could not be completed", + HttpStatus.INTERNAL_SERVER_ERROR, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Handle security exceptions + */ + @ExceptionHandler(SecurityException.class) + public ResponseEntity> handleSecurityException(SecurityException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.error("Security violation [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "SECURITY_VIOLATION", + "Security policy violation", + HttpStatus.FORBIDDEN, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.FORBIDDEN); + } + + /** + * Handle illegal argument exceptions (validation errors) + */ + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgumentException(IllegalArgumentException ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + logger.warn("Validation error [" + errorId + "]: " + ex.getMessage()); + + // For validation errors, we can be more specific but still safe + String sanitizedMessage = sanitizeValidationMessage(ex.getMessage()); + + Map response = createErrorResponse( + "VALIDATION_ERROR", + sanitizedMessage, + HttpStatus.BAD_REQUEST, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.BAD_REQUEST); + } + + /** + * Handle all other exceptions + */ + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGenericException(Exception ex, WebRequest request) { + String errorId = UUID.randomUUID().toString(); + + // Log full stack trace for debugging + logger.error("Unexpected error [" + errorId + "]: " + ex.getMessage()); + + Map response = createErrorResponse( + "INTERNAL_ERROR", + "An unexpected error occurred", + HttpStatus.INTERNAL_SERVER_ERROR, + errorId + ); + + return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + /** + * Creates a standardized error response + */ + private Map createErrorResponse(String errorCode, String message, HttpStatus status, String errorId) { + Map response = new HashMap<>(); + response.put("error", true); + response.put("errorCode", errorCode); + response.put("message", message); + response.put("status", status.value()); + response.put("timestamp", LocalDateTime.now().toString()); + response.put("errorId", errorId); // For support purposes + + return response; + } + + /** + * Sanitizes validation messages to prevent information leakage + */ + private String sanitizeValidationMessage(String message) { + if (message == null) { + return "Invalid input provided"; + } + + // Remove any potential sensitive information patterns + String sanitized = message.toLowerCase(); + + // Check for potentially sensitive patterns and replace with generic messages + if (sanitized.contains("sql") || sanitized.contains("database") || sanitized.contains("constraint")) { + return "Invalid input provided"; + } + + if (sanitized.contains("file") && (sanitized.contains("size") || sanitized.contains("type"))) { + return message; // File validation messages are generally safe to show + } + + if (sanitized.contains("password") && sanitized.contains("requirement")) { + return message; // Password requirement messages are safe + } + + // For other validation messages, return as-is if they seem safe + if (message.length() < 100 && !message.contains("Exception") && !message.contains("Error")) { + return message; + } + + return "Invalid input provided"; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java new file mode 100644 index 000000000..2e30375c4 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/IncorrectProviderException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class IncorrectProviderException extends RuntimeException { + public IncorrectProviderException(String message) { + super(message); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java new file mode 100644 index 000000000..1b924acbb --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/ILogger.java @@ -0,0 +1,11 @@ +package com.danielagapov.spawn.shared.exceptions.Logger; + +public interface ILogger { + void info(String message); + + void warn(String message); + + void error(String message); + + +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java new file mode 100644 index 000000000..15df3fe09 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Logger/Logger.java @@ -0,0 +1,40 @@ +package com.danielagapov.spawn.shared.exceptions.Logger; + +import org.springframework.stereotype.Service; + +@Service +public final class Logger implements ILogger { + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Logger.class); + + public void info(String message) { + logger.info(formatMessageWithCallerInfo(message)); + } + + public void warn(String message) { + logger.warn(formatMessageWithCallerInfo(message)); + } + + public void error(String message) { + logger.error(formatMessageWithCallerInfo(message)); + } + + /** + * Formats a message with caller information (file name, line number, and method name) + * + * @param message The original message to format + * @return A formatted message with caller information + */ + private String formatMessageWithCallerInfo(String message) { + StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace(); + // Index 0 is getStackTrace, index 1 is this method, index 2 is the logging method (info/warn/error), + // and index 3 is the actual caller we want + StackTraceElement caller = stackTrace[3]; + + String fileName = caller.getFileName(); + String methodName = caller.getMethodName(); + int lineNumber = caller.getLineNumber(); + + // Format the message with caller information: [FileName:LineNumber] MethodName - Message + return String.format("[%s:%d] %s - %s", fileName, lineNumber, methodName, message); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java new file mode 100644 index 000000000..9bf9bcbc5 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/OAuthProviderUnavailableException.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.shared.exceptions; + +/** + * Exception thrown when an OAuth provider service is unavailable + */ +public class OAuthProviderUnavailableException extends RuntimeException { + public OAuthProviderUnavailableException(String message) { + super(message); + } + + public OAuthProviderUnavailableException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java new file mode 100644 index 000000000..1b56c23b2 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/PhoneNumberAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.PHONE_NUMBER; + +public class PhoneNumberAlreadyExistsException extends FieldAlreadyExistsException { + public PhoneNumberAlreadyExistsException(String message) { + super(message, PHONE_NUMBER); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java new file mode 100644 index 000000000..af6fe6bb0 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/BadTokenException.java @@ -0,0 +1,4 @@ +package com.danielagapov.spawn.shared.exceptions.Token; + +public class BadTokenException extends RuntimeException { +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java new file mode 100644 index 000000000..a1953113e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/Token/TokenNotFoundException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions.Token; + +public class TokenNotFoundException extends RuntimeException { + public TokenNotFoundException(String message) { + super(message); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java new file mode 100644 index 000000000..7b8d9853e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TokenExpiredException.java @@ -0,0 +1,14 @@ +package com.danielagapov.spawn.shared.exceptions; + +/** + * Exception thrown when an OAuth token has expired + */ +public class TokenExpiredException extends RuntimeException { + public TokenExpiredException(String message) { + super(message); + } + + public TokenExpiredException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java new file mode 100644 index 000000000..c6943a1c8 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/TooManyAttemptsException.java @@ -0,0 +1,7 @@ +package com.danielagapov.spawn.shared.exceptions; + +public class TooManyAttemptsException extends RuntimeException{ + public TooManyAttemptsException(String message) { + super(message); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java new file mode 100644 index 000000000..53ed45242 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/exceptions/UsernameAlreadyExistsException.java @@ -0,0 +1,9 @@ +package com.danielagapov.spawn.shared.exceptions; + +import static com.danielagapov.spawn.shared.util.UserField.USERNAME; + +public class UsernameAlreadyExistsException extends FieldAlreadyExistsException { + public UsernameAlreadyExistsException(String message) { + super(message, USERNAME); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java new file mode 100644 index 000000000..4f3e9a72b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/EntityType.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.shared.util; + +public enum EntityType { + // Base Entities + ChatMessage("Chat Message"), + Activity("Activity"), + ActivityType("ActivityType"), + + User("User"), + FriendRequest("Friend Request"), + BetaAccessSignUp("Beta Access Sign Up"), + + // Related to Base Entities + Location("Location"), + ChatMessageLike("Chat Message Like"), + ActivityUser("Activity User"), + + // Unrelated to Base Entities + ExternalIdMap("External Id Map"), + ReportedContent("Reported Content"), + FeedbackSubmission("Feedback Submission"); + + private final String description; + + EntityType(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java new file mode 100644 index 000000000..c4e11bbf0 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ErrorResponse.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.util; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ErrorResponse { + private String message; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java new file mode 100644 index 000000000..1601d1603 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/InputValidationUtil.java @@ -0,0 +1,213 @@ +package com.danielagapov.spawn.shared.util; + +import java.util.regex.Pattern; + +/** + * Utility class for input validation and sanitization to prevent injection attacks + * and ensure data integrity + */ +public class InputValidationUtil { + + // Regex patterns for validation + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[a-zA-Z0-9._-]{3,30}$"); + private static final Pattern EMAIL_PATTERN = Pattern.compile("^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$"); + private static final Pattern PHONE_PATTERN = Pattern.compile("^\\+?[1-9]\\d{6,14}$"); // E.164 format (7-15 digits) + private static final Pattern NAME_PATTERN = Pattern.compile("^[a-zA-Z\\s'-]{1,100}$"); + private static final Pattern SAFE_TEXT_PATTERN = Pattern.compile("^[a-zA-Z0-9\\s.,!?'-]{1,500}$"); + + // Dangerous patterns to detect injection attempts + private static final Pattern SQL_INJECTION_PATTERN = Pattern.compile( + "(?i).*(union|select|insert|update|delete|drop|create|alter|exec|script|javascript|vbscript|onload|onerror).*" + ); + private static final Pattern XSS_PATTERN = Pattern.compile( + "(?i).*(]*>", ""); + + // Remove script-related content + sanitized = sanitized.replaceAll("(?i)(javascript:|vbscript:|data:)", ""); + + // Remove SQL injection attempts + sanitized = sanitized.replaceAll("(?i)(union|select|insert|update|delete|drop|create|alter|exec)", ""); + + // Remove path traversal attempts + sanitized = sanitized.replaceAll("(\\.\\./|\\.\\\\)", ""); + + // Trim whitespace + sanitized = sanitized.trim(); + + return sanitized; + } + + /** + * Sanitizes username by removing invalid characters + */ + public static String sanitizeUsername(String username) { + if (username == null) { + return null; + } + + // Keep only allowed characters + String sanitized = username.replaceAll("[^a-zA-Z0-9._-]", ""); + + // Ensure length constraints + if (sanitized.length() > 30) { + sanitized = sanitized.substring(0, 30); + } + + return sanitized.trim(); + } + + /** + * Sanitizes email by converting to lowercase and trimming + */ + public static String sanitizeEmail(String email) { + if (email == null) { + return null; + } + + return email.trim().toLowerCase(); + } + + /** + * Validates password strength + */ + public static boolean isStrongPassword(String password) { + if (password == null || password.length() < 8) { + return false; + } + + boolean hasUpper = password.chars().anyMatch(Character::isUpperCase); + boolean hasLower = password.chars().anyMatch(Character::isLowerCase); + boolean hasDigit = password.chars().anyMatch(Character::isDigit); + boolean hasSpecial = password.chars().anyMatch(ch -> "!@#$%^&*()_+-=[]{}|;:,.<>?".indexOf(ch) >= 0); + + return hasUpper && hasLower && hasDigit && hasSpecial && !containsDangerousContent(password); + } + + /** + * Checks if content contains potentially dangerous patterns + */ + private static boolean containsDangerousContent(String content) { + if (content == null) { + return false; + } + + String lowerContent = content.toLowerCase(); + + return SQL_INJECTION_PATTERN.matcher(lowerContent).matches() || + XSS_PATTERN.matcher(lowerContent).matches() || + PATH_TRAVERSAL_PATTERN.matcher(lowerContent).matches(); + } + + /** + * Validates UUID format + */ + public static boolean isValidUUID(String uuid) { + if (uuid == null || uuid.trim().isEmpty()) { + return false; + } + + Pattern uuidPattern = Pattern.compile( + "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" + ); + + return uuidPattern.matcher(uuid.trim()).matches(); + } + + /** + * Validates that a string is within allowed length limits + */ + public static boolean isValidLength(String text, int minLength, int maxLength) { + if (text == null) { + return minLength == 0; // null is only valid if minimum length is 0 + } + + int length = text.trim().length(); + return length >= minLength && length <= maxLength; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java new file mode 100644 index 000000000..4bd115247 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/LoggingUtils.java @@ -0,0 +1,12 @@ +package com.danielagapov.spawn.shared.util; + +import java.util.UUID; + +public final class LoggingUtils { + private LoggingUtils() {} + + public static String formatUserIdInfo(UUID userId) { + if (userId == null) return "null userId"; + return userId.toString() + " (full user info not available)"; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/NotificationType.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/NotificationType.java new file mode 100644 index 000000000..884a912a2 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/NotificationType.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.shared.util; + +/** + * Enum for different types of notifications in the application + */ +public enum NotificationType { + Activity_INVITE, + Activity_UPDATE, + Activity_PARTICIPATION, + Activity_PARTICIPATION_REVOKED, + NEW_COMMENT, + FRIEND_REQUEST, + FRIEND_REQUEST_ACCEPTED, + PUSH_ENABLED, + PUSH_REGISTRATION +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java new file mode 100644 index 000000000..1bf4b19f7 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/OAuthProvider.java @@ -0,0 +1,5 @@ +package com.danielagapov.spawn.shared.util; + +public enum OAuthProvider { + google, apple +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ParticipationStatus.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ParticipationStatus.java new file mode 100644 index 000000000..5a2e34901 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/ParticipationStatus.java @@ -0,0 +1,2 @@ +package com.danielagapov.spawn.shared.util; +public enum ParticipationStatus { participating, invited, notInvited; } diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java new file mode 100644 index 000000000..3d2f04e0e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberMatchingUtil.java @@ -0,0 +1,157 @@ +package com.danielagapov.spawn.shared.util; + +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.regex.Pattern; + +/** + * Utility class for phone number matching and comparison that doesn't make assumptions + * about country codes. This replaces the unreliable approach of forcing +1 on all numbers. + */ +@Component +public final class PhoneNumberMatchingUtil { + + private static final Pattern DIGITS_ONLY_PATTERN = Pattern.compile("[^0-9]"); + private static final Pattern PHONE_VALIDATION_PATTERN = Pattern.compile("^\\+?[1-9]\\d{7,14}$"); + + /** + * Normalizes a phone number for storage without making country code assumptions. + * Only cleans formatting but preserves the actual number structure. + */ + public static String normalizeForStorage(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return null; + } + + // Remove all non-digit characters except + + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + + // If it's empty after cleaning, return null + if (cleaned.isEmpty()) { + return null; + } + + // Validate basic format (must have + and reasonable length) + if (cleaned.startsWith("+") && PHONE_VALIDATION_PATTERN.matcher(cleaned).matches()) { + return cleaned; + } + + // If no + prefix and looks like a reasonable number, keep as-is for now + // Let the user or admin decide the country code later + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(cleaned).replaceAll(""); + if (digitsOnly.length() >= 7 && digitsOnly.length() <= 15) { + return cleaned.startsWith("+") ? cleaned : digitsOnly; + } + + return null; // Invalid format + } + + /** + * Generates multiple possible formats for a phone number to enable flexible matching. + * This allows us to match phone numbers even when they're stored in different formats. + */ + public static Set generateMatchingVariants(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return Collections.emptySet(); + } + + Set variants = new HashSet<>(); + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(phoneNumber).replaceAll(""); + + if (digitsOnly.length() < 7) { + return Collections.emptySet(); // Too short to be valid + } + + // Add the original (cleaned) + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + if (!cleaned.isEmpty()) { + variants.add(cleaned); + } + + // Add digits-only version + variants.add(digitsOnly); + + // If it already has a country code, add without it + if (cleaned.startsWith("+")) { + variants.add(digitsOnly); + } + + // For common formats, add likely variants + if (digitsOnly.length() == 10) { + // Could be US/Canada without country code + variants.add("+1" + digitsOnly); + variants.add("1" + digitsOnly); + } else if (digitsOnly.length() == 11 && digitsOnly.startsWith("1")) { + // Could be US/Canada with 1 prefix + variants.add("+" + digitsOnly); + variants.add(digitsOnly.substring(1)); // Remove the leading 1 + variants.add("+1" + digitsOnly.substring(1)); + } + + // Add common international prefixes for the same base number + if (!digitsOnly.startsWith("1") && digitsOnly.length() >= 9 && digitsOnly.length() <= 11) { + // Could be other countries - add some common patterns but don't assume + variants.add("+" + digitsOnly); + } + + return variants; + } + + /** + * Checks if two phone numbers could be the same, considering various formatting differences. + */ + public static boolean couldMatch(String phoneNumber1, String phoneNumber2) { + if (phoneNumber1 == null || phoneNumber2 == null) { + return false; + } + + Set variants1 = generateMatchingVariants(phoneNumber1); + Set variants2 = generateMatchingVariants(phoneNumber2); + + // Check if any variants overlap + for (String variant1 : variants1) { + if (variants2.contains(variant1)) { + return true; + } + } + + return false; + } + + /** + * Validates if a phone number has a reasonable format without making country assumptions. + */ + public static boolean isReasonablePhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + String cleaned = phoneNumber.replaceAll("[^+\\d]", ""); + String digitsOnly = DIGITS_ONLY_PATTERN.matcher(cleaned).replaceAll(""); + + // Check for obviously invalid patterns + if (phoneNumber.contains("@") || // Email + phoneNumber.contains("-") && phoneNumber.length() > 20 || // UUID-like + phoneNumber.matches(".*[a-zA-Z].*") && !phoneNumber.contains("@")) { // Letters but not email + return false; + } + + // Check digit count + return digitsOnly.length() >= 7 && digitsOnly.length() <= 15; + } + + /** + * Extracts all possible search variants for database queries. + * Used when searching for users by phone numbers. + */ + public static List getSearchVariants(List phoneNumbers) { + Set allVariants = new HashSet<>(); + + for (String phoneNumber : phoneNumbers) { + allVariants.addAll(generateMatchingVariants(phoneNumber)); + } + + return new ArrayList<>(allVariants); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java new file mode 100644 index 000000000..a128b6041 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/PhoneNumberValidator.java @@ -0,0 +1,96 @@ +package com.danielagapov.spawn.shared.util; + +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.regex.Pattern; + +@Component +public final class PhoneNumberValidator { + + private static final String PHONE_REGEX = "^\\+?[1-9]\\d{1,14}$"; + private static final Pattern PHONE_PATTERN = Pattern.compile(PHONE_REGEX); + + // Common country codes and their specific patterns + private static final Map COUNTRY_PATTERNS = Map.of( + "+1", "^\\+1[2-9]\\d{2}[2-9]\\d{2}\\d{4}$", // US/Canada + "+44", "^\\+44[1-9]\\d{8,9}$", // UK + "+91", "^\\+91[6-9]\\d{9}$", // India + "+86", "^\\+86[1][3-9]\\d{9}$", // China + "+33", "^\\+33[1-9]\\d{8}$" // France + ); + + public static boolean isValidPhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + String cleanNumber = cleanPhoneNumber(phoneNumber); + + // Basic format validation + if (cleanNumber == null || !PHONE_PATTERN.matcher(cleanNumber).matches()) { + return false; + } + + // Country-specific validation + return validateByCountryCode(cleanNumber); + } + + /** + * Cleans a phone number without making assumptions about country codes. + * This replaces the old approach that defaulted to +1. + */ + public static String cleanPhoneNumber(String phoneNumber) { + if (phoneNumber == null) return null; + + System.out.println("🧹 BACKEND CLEANING PHONE: '" + phoneNumber + "'"); + + // Check if it's obviously not a phone number first + if (!PhoneNumberMatchingUtil.isReasonablePhoneNumber(phoneNumber)) { + System.out.println(" REJECTED: Not a reasonable phone number format"); + return null; + } + + // Use the new matching util for normalization + String normalized = PhoneNumberMatchingUtil.normalizeForStorage(phoneNumber); + System.out.println(" NORMALIZED: '" + normalized + "'"); + + // If we got a clean result, return it + if (normalized != null && !normalized.trim().isEmpty()) { + System.out.println(" FINAL RESULT: '" + normalized + "'"); + return normalized; + } + + System.out.println(" FINAL RESULT: null (invalid)"); + return null; + } + + private static boolean validateByCountryCode(String phoneNumber) { + // If it has a + prefix, try country-specific validation + if (phoneNumber.startsWith("+")) { + for (Map.Entry entry : COUNTRY_PATTERNS.entrySet()) { + if (phoneNumber.startsWith(entry.getKey())) { + return Pattern.compile(entry.getValue()).matcher(phoneNumber).matches(); + } + } + } + + // For numbers without country codes, use general validation + // Don't assume any specific country + String digitsOnly = phoneNumber.replaceAll("[^0-9]", ""); + return digitsOnly.length() >= 7 && digitsOnly.length() <= 15; + } + + public static String getCountryCode(String phoneNumber) { + String cleaned = cleanPhoneNumber(phoneNumber); + if (cleaned == null || !cleaned.startsWith("+")) return null; + + for (String countryCode : COUNTRY_PATTERNS.keySet()) { + if (cleaned.startsWith(countryCode)) { + return countryCode; + } + } + + return null; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java new file mode 100644 index 000000000..0f8d53ab4 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/RetryHelper.java @@ -0,0 +1,80 @@ +package com.danielagapov.spawn.shared.util; + +import java.time.Duration; +import java.util.concurrent.Callable; +import java.util.function.Predicate; + +/** + * Utility class for implementing retry logic with exponential backoff + */ +public class RetryHelper { + + /** + * Executes a callable with retry logic using exponential backoff + * + * @param callable The operation to retry + * @param maxRetries Maximum number of retry attempts + * @param initialDelay Initial delay before first retry + * @param retryOnException Predicate to determine if exception should trigger retry + * @param Return type of the callable + * @return Result of the successful operation + * @throws Exception The last exception if all retries fail + */ + public static T executeWithRetry( + Callable callable, + int maxRetries, + Duration initialDelay, + Predicate retryOnException) throws Exception { + + Exception lastException = null; + Duration currentDelay = initialDelay; + + for (int attempt = 0; attempt <= maxRetries; attempt++) { + try { + return callable.call(); + } catch (Exception e) { + lastException = e; + + if (attempt == maxRetries || !retryOnException.test(e)) { + throw e; + } + + // Wait before retrying + try { + Thread.sleep(currentDelay.toMillis()); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Retry interrupted", ie); + } + + // Exponential backoff + currentDelay = currentDelay.multipliedBy(2); + } + } + + throw lastException; + } + + /** + * Convenience method for OAuth-related retries + */ + public static T executeOAuthWithRetry(Callable callable) throws Exception { + return executeWithRetry( + callable, + 3, + Duration.ofMillis(500), + exception -> isRetryableException(exception) + ); + } + + /** + * Determines if an exception is retryable for OAuth operations + */ + private static boolean isRetryableException(Exception e) { + // Network timeouts, connection errors, etc. + return e instanceof java.net.ConnectException + || e instanceof java.net.SocketTimeoutException + || e instanceof java.io.IOException + || (e.getCause() != null && isRetryableException((Exception) e.getCause())); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserField.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserField.java new file mode 100644 index 000000000..8e8286358 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserField.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.util; + +public enum UserField { + USERNAME, + EMAIL, + PHONE_NUMBER, + EXTERNAL_ID +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java new file mode 100644 index 000000000..da317db8e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserRelationshipType.java @@ -0,0 +1,8 @@ +package com.danielagapov.spawn.shared.util; + +public enum UserRelationshipType { + FRIEND, + RECOMMENDED_FRIEND, + INCOMING_FRIEND_REQUEST, + OUTGOING_FRIEND_REQUEST +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java new file mode 100644 index 000000000..4b1b39a32 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/UserStatus.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.shared.util; + +/** + * Each status represents the most recently completed step in the registration process. + * For example, if a user has status EMAIL_VERIFIED, they have completed the email verification step but not the USERNAME_AND_PHONE_NUMBER step. + * ADMIN is a special status for admin users who have access to admin-only endpoints. + */ +public enum UserStatus { + EMAIL_VERIFIED, + USERNAME_AND_PHONE_NUMBER, + NAME_AND_PHOTO, + CONTACT_IMPORT, + ACTIVE, + ADMIN +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java new file mode 100644 index 000000000..f302c4655 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/util/VerificationCodeGenerator.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.util; + +import java.security.SecureRandom; + +/** + * Utility class for generating verification codes + */ +public final class VerificationCodeGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + private static final String DIGITS = "0123456789"; + + /** + * Generates a 6-digit verification code + * @return a 6-digit string code + */ + public static String generateVerificationCode() { + StringBuilder code = new StringBuilder(6); + for (int i = 0; i < 6; i++) { + code.append(DIGITS.charAt(RANDOM.nextInt(DIGITS.length()))); + } + return code.toString(); + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java new file mode 100644 index 000000000..395a672fc --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/NameValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidName annotation + */ +public class NameValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidName constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String name, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (name == null || name.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (name == null || name.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidName(name); + } +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java new file mode 100644 index 000000000..4bbf35206 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/PhoneNumberValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidPhoneNumber annotation + */ +public class PhoneNumberValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidPhoneNumber constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String phoneNumber, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (phoneNumber == null || phoneNumber.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (phoneNumber == null || phoneNumber.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidPhoneNumber(phoneNumber); + } +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java new file mode 100644 index 000000000..4f9c65075 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/UsernameValidator.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.validation; + +import com.danielagapov.spawn.shared.util.InputValidationUtil; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +/** + * Validator implementation for @ValidUsername annotation + */ +public class UsernameValidator implements ConstraintValidator { + + private boolean optional; + + @Override + public void initialize(ValidUsername constraintAnnotation) { + this.optional = constraintAnnotation.optional(); + } + + @Override + public boolean isValid(String username, ConstraintValidatorContext context) { + // If optional and null/empty, it's valid + if (optional && (username == null || username.trim().isEmpty())) { + return true; + } + + // If not optional and null/empty, it's invalid + if (username == null || username.trim().isEmpty()) { + return false; + } + + // Use existing validation utility + return InputValidationUtil.isValidUsername(username); + } +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java new file mode 100644 index 000000000..e97634e90 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidName.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a name follows standard conventions: + * - 1-100 characters long + * - Only letters, spaces, hyphens, and apostrophes + * - No dangerous content (SQL injection, XSS, etc.) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = NameValidator.class) +@Documented +public @interface ValidName { + String message() default "Name must be 1-100 characters and contain only letters, spaces, hyphens, and apostrophes"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java new file mode 100644 index 000000000..d2d28c95b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidPhoneNumber.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a phone number follows E.164 format: + * - Optional + prefix + * - 1-15 digits + * - No spaces or special characters (except in display format) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PhoneNumberValidator.class) +@Documented +public @interface ValidPhoneNumber { + String message() default "Phone number must be in valid E.164 format"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java new file mode 100644 index 000000000..3f0b99379 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/shared/validation/ValidUsername.java @@ -0,0 +1,25 @@ +package com.danielagapov.spawn.shared.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +/** + * Validates that a username follows standard conventions: + * - 3-30 characters long + * - Only alphanumeric characters, dots, underscores, and hyphens + * - No spaces allowed + * - No dangerous content (SQL injection, XSS, etc.) + */ +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = UsernameValidator.class) +@Documented +public @interface ValidUsername { + String message() default "Username must be 3-30 characters and contain only letters, numbers, dots, underscores, and hyphens (no spaces)"; + Class[] groups() default {}; + Class[] payload() default {}; + boolean optional() default false; +} + diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java new file mode 100644 index 000000000..7050059cd --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AbstractUserDTO.java @@ -0,0 +1,42 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.validation.ValidName; +import com.danielagapov.spawn.shared.validation.ValidUsername; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@AllArgsConstructor +@Getter +@Setter +@NoArgsConstructor +// Abstract class since interface describes behaviours +public abstract class AbstractUserDTO implements Serializable { + private UUID id; + + @ValidName(optional = true) + private String name; + + @Email(message = "Email must be valid") + private String email; + + @ValidUsername(optional = true) + private String username; + + @Size(max = 500, message = "Bio must not exceed 500 characters") + private String bio; + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; // Check if the same reference + if (obj == null || getClass() != obj.getClass()) return false; // Null check and class check + AbstractUserDTO that = (AbstractUserDTO) obj; // Safe cast + return id != null && id.equals(that.id); // Compare IDs + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java new file mode 100644 index 000000000..b2b41ffeb --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthResponseDTO.java @@ -0,0 +1,30 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.util.UserStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class AuthResponseDTO implements Serializable { + private BaseUserDTO user; + private UserStatus status; + private Boolean isOAuthUser; + + public AuthResponseDTO(BaseUserDTO user, UserStatus status) { + this.user = user; + this.status = status; + } + + public AuthResponseDTO(BaseUserDTO user, UserStatus status, boolean isOAuthUser) { + this.user = user; + this.status = status; + this.isOAuthUser = isOAuthUser; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java new file mode 100644 index 000000000..756227769 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/AuthUserDTO.java @@ -0,0 +1,32 @@ +package com.danielagapov.spawn.user.api.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * DTO for email/password authentication and registration. + * + * Note: This DTO is ONLY used for email/password flows where a password is required. + * For OAuth authentication (Google, Apple), use OAuthRegistrationDTO instead, + * which does not have a password field. + * + * Inherits validation from AbstractUserDTO for username, name, email, and bio. + */ +@NoArgsConstructor +@Getter +@Setter +public class AuthUserDTO extends AbstractUserDTO { + @NotBlank(message = "Password is required") + @Size(min = 8, message = "Password must be at least 8 characters") + private String password; + + public AuthUserDTO(UUID id, String name, String email, String username, String bio, String password) { + super(id, name, email, username, bio); + this.password = password; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java new file mode 100644 index 000000000..20b875831 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/BaseUserDTO.java @@ -0,0 +1,68 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.util.UserRelationshipType; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +public class BaseUserDTO extends AbstractUserDTO { + private String profilePicture; + private Boolean hasCompletedOnboarding; + private String provider; // Auth provider: "google", "apple", or "email" + private UserRelationshipType relationshipStatus; // Relationship status relative to requesting user + private UUID pendingFriendRequestId; // ID of pending friend request if one exists + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = false; // Default value for backward compatibility + this.provider = null; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, Boolean hasCompletedOnboarding) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = null; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + public BaseUserDTO(UUID id, String name, String email, String username, String bio, String profilePicture, Boolean hasCompletedOnboarding, String provider) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = provider; + this.relationshipStatus = null; + this.pendingFriendRequestId = null; + } + + @JsonCreator + public BaseUserDTO( + @JsonProperty("id") UUID id, + @JsonProperty("name") String name, + @JsonProperty("email") String email, + @JsonProperty("username") String username, + @JsonProperty("bio") String bio, + @JsonProperty("profilePicture") String profilePicture, + @JsonProperty("hasCompletedOnboarding") Boolean hasCompletedOnboarding, + @JsonProperty("provider") String provider, + @JsonProperty("relationshipStatus") UserRelationshipType relationshipStatus, + @JsonProperty("pendingFriendRequestId") UUID pendingFriendRequestId) { + super(id, name, email, username, bio); + this.profilePicture = profilePicture; + this.hasCompletedOnboarding = hasCompletedOnboarding != null ? hasCompletedOnboarding : false; + this.provider = provider; + this.relationshipStatus = relationshipStatus; + this.pendingFriendRequestId = pendingFriendRequestId; + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java new file mode 100644 index 000000000..1d27d59d4 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/FriendUser/MinimalFriendDTO.java @@ -0,0 +1,33 @@ +package com.danielagapov.spawn.user.api.dto.FriendUser; + +import com.danielagapov.spawn.user.api.dto.BaseUserDTO; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Minimal DTO for friend users (id, username, name, profilePicture). + */ +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class MinimalFriendDTO implements Serializable { + private UUID id; + private String username; + private String name; + private String profilePicture; + + public static MinimalFriendDTO fromBaseUserDTO(BaseUserDTO baseUser) { + return new MinimalFriendDTO( + baseUser.getId(), + baseUser.getUsername(), + baseUser.getName(), + baseUser.getProfilePicture() + ); + } +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java new file mode 100644 index 000000000..762799ccf --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/LoginDTO.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@AllArgsConstructor +@NoArgsConstructor +@Getter +public class LoginDTO implements Serializable { + private String usernameOrEmail; + private String password; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java new file mode 100644 index 000000000..c8b308394 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/OptionalDetailsDTO.java @@ -0,0 +1,15 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.io.Serializable; + +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class OptionalDetailsDTO implements Serializable { + private String name; + private byte[] profilePictureData; +} diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java new file mode 100644 index 000000000..fc726e04b --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/PasswordChangeDTO.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * DTO used for password change requests + */ +@Getter +@AllArgsConstructor +@NoArgsConstructor +public class PasswordChangeDTO { + private String currentPassword; + private String newPassword; +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java new file mode 100644 index 000000000..c1a40da0e --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UpdateUserDetailsDTO.java @@ -0,0 +1,29 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.danielagapov.spawn.shared.validation.ValidPhoneNumber; +import com.danielagapov.spawn.shared.validation.ValidUsername; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@AllArgsConstructor +@NoArgsConstructor +public class UpdateUserDetailsDTO implements Serializable { + @NotNull(message = "User ID is required") + private UUID id; + + @ValidUsername + private String username; + + @ValidPhoneNumber + private String phoneNumber; + + private String password; +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java new file mode 100644 index 000000000..4c1f7c7dd --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserCreationDTO.java @@ -0,0 +1,24 @@ +package com.danielagapov.spawn.user.api.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.UUID; + +/** + * DTO for user creation (legacy OAuth flow). + * Note: This inherits validation from AbstractUserDTO for username, name, email, and bio. + * Password is NOT part of this DTO as it's used for OAuth flows. + */ +@NoArgsConstructor +@Getter +@Setter +public class UserCreationDTO extends AbstractUserDTO { + private byte[] profilePictureData; // raw image uploaded + + public UserCreationDTO(UUID id, String username, byte[] profilePictureData, String name, String bio, String email) { + super(id, name, email, username, bio); + this.profilePictureData = profilePictureData; + } +} \ No newline at end of file diff --git a/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java new file mode 100644 index 000000000..609d5cc24 --- /dev/null +++ b/shared/spawn-common/src/main/java/com/danielagapov/spawn/user/api/dto/UserDTO.java @@ -0,0 +1,34 @@ +package com.danielagapov.spawn.user.api.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.Setter; + +import java.util.List; +import java.util.UUID; + +@Getter +@Setter +public class UserDTO extends BaseUserDTO { + List friendUserIds; + + public UserDTO(UUID id, List friendUserIds, String username, String picture, String name, String bio, String email) { + super(id, name, email, username, bio, picture); + this.friendUserIds = friendUserIds; + } + + @JsonCreator + public UserDTO( + @JsonProperty("id") UUID id, + @JsonProperty("friendUserIds") List friendUserIds, + @JsonProperty("username") String username, + @JsonProperty("profilePicture") String picture, + @JsonProperty("name") String name, + @JsonProperty("bio") String bio, + @JsonProperty("email") String email, + @JsonProperty("hasCompletedOnboarding") Boolean hasCompletedOnboarding) { + super(id, name, email, username, bio, picture, hasCompletedOnboarding); + this.friendUserIds = friendUserIds; + } +} \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/Controllers/README.md b/src/main/java/com/danielagapov/spawn/Controllers/README.md deleted file mode 100644 index 3ba35664b..000000000 --- a/src/main/java/com/danielagapov/spawn/Controllers/README.md +++ /dev/null @@ -1 +0,0 @@ -Controllers contain endpoints for the API to GET, POST, PUT, and DELETE data from the database, using HTTP requests. \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/DTOs/README.md b/src/main/java/com/danielagapov/spawn/DTOs/README.md deleted file mode 100644 index f35d8f3e2..000000000 --- a/src/main/java/com/danielagapov/spawn/DTOs/README.md +++ /dev/null @@ -1,19 +0,0 @@ -DTOs (Data Transfer Object) are used to encapsulate which data needs to be sent around, from database models. - -From my understanding, through using them a lot at work (in C# .NET), they might conceal some of the complexity of the database models, and also allow for more flexibility in the future, if the database models change. - -That way, you can separate the actual models (or entities) from what you send around. - -An example: - -``` -public class UserDTO { - private UUID id, - private String name, -} implements Serializable -``` -`Serializable` means it can be sent via. JSON through network requests. Per Oracle docs: -" -Classes that do not implement this interface will not -have any of their state serialized or deserialized -." diff --git a/src/main/java/com/danielagapov/spawn/Models/README.md b/src/main/java/com/danielagapov/spawn/Models/README.md deleted file mode 100644 index c03b7bd07..000000000 --- a/src/main/java/com/danielagapov/spawn/Models/README.md +++ /dev/null @@ -1,4 +0,0 @@ -Models, otherwise called "entities," are the classes that represent the database tables. They're what are retrieved and put into the database. - -In the Swift side, I've defined these entities (of which, we won't use `AppUser` in the back-end, as that's just for the mobile side). FriendTag has been replaced by the Friendship model. - diff --git a/src/main/java/com/danielagapov/spawn/Services/README.md b/src/main/java/com/danielagapov/spawn/Services/README.md deleted file mode 100644 index 617e140bf..000000000 --- a/src/main/java/com/danielagapov/spawn/Services/README.md +++ /dev/null @@ -1 +0,0 @@ -This folder contains service classes that implement business logic. Controllers use these services to perform operations on data. \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/SpawnApplication.java b/src/main/java/com/danielagapov/spawn/SpawnApplication.java index 0b2bd55c1..1f2b4b2dd 100644 --- a/src/main/java/com/danielagapov/spawn/SpawnApplication.java +++ b/src/main/java/com/danielagapov/spawn/SpawnApplication.java @@ -5,12 +5,14 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration; import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.data.jpa.repository.config.EnableJpaRepositories; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = {RedisRepositoriesAutoConfiguration.class}) @EnableJpaRepositories +@EnableFeignClients @EnableScheduling @EnableCaching @EnableAsync diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageRepository.java b/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageRepository.java deleted file mode 100644 index d48d24923..000000000 --- a/src/main/java/com/danielagapov/spawn/activity/internal/repositories/IChatMessageRepository.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.danielagapov.spawn.activity.internal.repositories; - -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -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; -import java.util.UUID; - -@Repository -public interface IChatMessageRepository extends JpaRepository { - - @Query("SELECT cm FROM ChatMessage cm WHERE cm.activity.id = :activityId ORDER BY cm.timestamp DESC") - List getChatMessagesByActivityIdOrderByTimestampDesc(@Param("activityId") UUID activityId); - - /** - * Batch query to get chat message IDs for multiple activities at once. - * This prevents N+1 query problems when loading multiple activities. - * - * @param activityIds List of activity IDs to get chat messages for - * @return Map data as Object[] with activity ID and chat message ID - */ - @Query("SELECT cm.activity.id, cm.id FROM ChatMessage cm WHERE cm.activity.id IN :activityIds ORDER BY cm.activity.id, cm.timestamp DESC") - List findChatMessageIdsByActivityIds(@Param("activityIds") List activityIds); - - /** - * Batch query to get all chat messages for multiple activities. - * - * @param activityIds List of activity IDs - * @return List of ChatMessage objects for all requested activities - */ - @Query("SELECT cm FROM ChatMessage cm WHERE cm.activity.id IN :activityIds ORDER BY cm.activity.id, cm.timestamp DESC") - List findAllByActivityIds(@Param("activityIds") List activityIds); -} - diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarEventHandler.java b/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarEventHandler.java deleted file mode 100644 index 1bb64d88d..000000000 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarEventHandler.java +++ /dev/null @@ -1,105 +0,0 @@ -package com.danielagapov.spawn.activity.internal.services; - -import com.danielagapov.spawn.shared.events.ActivityInviteNotificationEvent; -import com.danielagapov.spawn.shared.events.ActivityUpdateNotificationEvent; -import com.danielagapov.spawn.shared.events.ActivityParticipationNotificationEvent; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; - -import java.util.UUID; - -/** - * Event handler for calendar-related events. - * Clears calendar cache when activities are created, updated, or deleted. - */ -@Component -public final class CalendarEventHandler { - - private final ICalendarService calendarService; - private final ILogger logger; - - public CalendarEventHandler(ICalendarService calendarService, ILogger logger) { - this.calendarService = calendarService; - this.logger = logger; - } - - /** - * Handler for Activity invite notifications. - * Clears the calendar cache for all users involved. - */ - @EventListener - public void handleActivityInviteNotification(ActivityInviteNotificationEvent event) { - // Get the Activity ID from the notification data - String activityIdStr = event.getData().get("activityId"); - String creatorIdStr = event.getData().get("creatorId"); - - if (creatorIdStr != null) { - try { - UUID creatorId = UUID.fromString(creatorIdStr); - clearCacheForUser(creatorId); - } catch (IllegalArgumentException e) { - logger.error("Invalid creator ID format in activity notification: " + creatorIdStr); - } - } - - // Clear cache for all target users (invitees) - event.getTargetUserIds().forEach(this::clearCacheForUser); - } - - /** - * Handler for Activity update notifications. - * Clears the calendar cache for all users involved. - */ - @EventListener - public void handleActivityUpdateNotification(ActivityUpdateNotificationEvent event) { - // Get the Activity ID and creator ID from the notification data - String activityIdStr = event.getData().get("activityId"); - String creatorIdStr = event.getData().get("creatorId"); - - if (creatorIdStr != null) { - try { - UUID creatorId = UUID.fromString(creatorIdStr); - clearCacheForUser(creatorId); - } catch (IllegalArgumentException e) { - logger.error("Invalid creator ID format in activity notification: " + creatorIdStr); - } - } - - // Clear cache for all target users - event.getTargetUserIds().forEach(this::clearCacheForUser); - } - - /** - * Handler for Activity participation changes. - * Clears the calendar cache for the user whose participation status changed. - */ - @EventListener - public void handleActivityParticipationChange(ActivityParticipationNotificationEvent event) { - // Get the user ID from the notification data - String userIdStr = event.getData().get("userId"); - if (userIdStr != null) { - try { - UUID userId = UUID.fromString(userIdStr); - clearCacheForUser(userId); - } catch (IllegalArgumentException e) { - logger.error("Invalid user ID format in participation notification: " + userIdStr); - } - } - - // Clear cache for activity creator (who is the target of the notification) - event.getTargetUserIds().forEach(this::clearCacheForUser); - } - - /** - * Helper method to clear the calendar cache for a specific user. - */ - private void clearCacheForUser(UUID userId) { - try { - calendarService.clearCalendarCache(userId); - logger.info("Cleared calendar cache for user: " + userId); - } catch (Exception e) { - logger.error("Error clearing calendar cache for user " + userId + ": " + e.getMessage()); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarService.java b/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarService.java deleted file mode 100644 index ea4ace68d..000000000 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/CalendarService.java +++ /dev/null @@ -1,355 +0,0 @@ -package com.danielagapov.spawn.activity.internal.services; - -import com.danielagapov.spawn.activity.api.dto.CalendarActivityDTO; -import com.danielagapov.spawn.shared.util.ParticipationStatus; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.ActivityUser; -import com.danielagapov.spawn.activity.internal.repositories.IActivityRepository; -import com.danielagapov.spawn.activity.internal.repositories.IActivityUserRepository; -import com.danielagapov.spawn.shared.util.CacheEvictionHelper; -import com.danielagapov.spawn.shared.util.CacheNames; -import org.springframework.cache.annotation.Cacheable; -import org.springframework.stereotype.Service; - -import java.time.LocalDate; -import java.time.YearMonth; -import java.time.format.DateTimeFormatter; -import java.util.*; - -@Service -public class CalendarService implements ICalendarService { - - private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd"); - private final ILogger logger; - private final IActivityRepository ActivityRepository; - private final IActivityUserRepository activityUserRepository; - private final CacheEvictionHelper cacheEvictionHelper; - - public CalendarService( - ILogger logger, - IActivityRepository ActivityRepository, - IActivityUserRepository activityUserRepository, - CacheEvictionHelper cacheEvictionHelper - ) { - this.logger = logger; - this.ActivityRepository = ActivityRepository; - this.activityUserRepository = activityUserRepository; - this.cacheEvictionHelper = cacheEvictionHelper; - } - - /** - * Get calendar activities for a user based on optional month and year filters - */ - @Override - @Cacheable(value = CacheNames.FILTERED_CALENDAR_ACTIVITIES, key = "{#userId, #month, #year}") - public List getCalendarActivitiesWithFilters(UUID userId, Integer month, Integer year) { - logger.info("Getting calendar activities with filters for user: " + userId + - (month != null ? ", month: " + month : "") + - (year != null ? ", year: " + year : "")); - - try { - // If month and year are provided, get activities for that specific month - if (month != null && year != null) { - logger.info("Using month/year filter for user: " + userId); - List activities = getCalendarActivitiesForUser(month, year, userId); - logger.info("Found " + activities.size() + " activities with month/year filter for user: " + userId); - return activities; - } - // Otherwise, get all activities - else { - logger.info("No filters provided, getting all activities for user: " + userId); - List activities = getAllCalendarActivitiesForUser(userId); - logger.info("Found " + activities.size() + " total activities (no filters) for user: " + userId); - return activities; - } - } catch (Exception e) { - logger.error("Error getting calendar activities with filters for user: " + userId + - (month != null ? ", month: " + month : "") + - (year != null ? ", year: " + year : "") + - ". Error: " + e.getMessage() + - ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } - - /** - * Get calendar activities for a specific user, month, and year - */ - @Override - @Cacheable(value = CacheNames.CALENDAR_ACTIVITIES, key = "{#userId, #month, #year}") - public List getCalendarActivitiesForUser(int month, int year, UUID userId) { - logger.info("Getting calendar activities for user: " + userId + ", month: " + month + ", year: " + year); - - try { - // Define the month range for filtering - YearMonth yearMonth = YearMonth.of(year, month); - LocalDate startOfMonth = yearMonth.atDay(1); - LocalDate endOfMonth = yearMonth.atEndOfMonth(); - - // Call the helper method with month/year filtering - List activities = fetchCalendarActivities(userId, startOfMonth, endOfMonth); - - logger.info("Found " + activities.size() + " calendar activities for user: " + userId + - " in month: " + month + ", year: " + year); - return activities; - } catch (Exception e) { - logger.error("Error getting calendar activities for user: " + userId + ", month: " + month + ", year: " + year + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } - - /** - * Get all calendar activities for a specific user - */ - @Override - @Cacheable(value = CacheNames.ALL_CALENDAR_ACTIVITIES, key = "#userId") - public List getAllCalendarActivitiesForUser(UUID userId) { - logger.info("Getting all calendar activities for user: " + userId); - - try { - // Call the helper method with no date filtering - List activities = fetchCalendarActivities(userId, null, null); - - logger.info("Found " + activities.size() + " total calendar activities for user: " + userId); - return activities; - } catch (Exception e) { - logger.error("Error getting all calendar activities for user: " + userId + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } - - /** - * Helper method to fetch calendar activities with optional date filtering - * - * @param userId User ID to get activities for - * @param startDate Start date for filtering (inclusive), or null for no start date filter - * @param endDate End date for filtering (inclusive), or null for no end date filter - * @return List of calendar activities matching the criteria - */ - private List fetchCalendarActivities(UUID userId, LocalDate startDate, LocalDate endDate) { - List activities = new ArrayList<>(); - - try { - logger.info("Fetching calendar activities for user: " + userId + - (startDate != null ? ", startDate: " + startDate : "") + - (endDate != null ? ", endDate: " + endDate : "")); - - // 1. Get Activities the user created - logger.info("About to query Activities created by user: " + userId); - List createdActivities = ActivityRepository.findByCreatorId(userId); - logger.info("Found " + createdActivities.size() + " Activities created by user: " + userId); - - // 2. Get Activities the user is participating in - logger.info("About to query Activities user is participating in for userId: " + userId); - List participatingActivities = activityUserRepository.findByUser_IdAndStatus(userId, ParticipationStatus.participating); - logger.info("Found " + participatingActivities.size() + " Activities user is participating in, userId: " + userId); - - // Process Activities created by the user - logger.info("Starting to process " + createdActivities.size() + " created Activities for user: " + userId); - for (Activity Activity : createdActivities) { - try { - logger.info("Processing created Activity ID: " + Activity.getId() + " for user: " + userId); - - // Add null safety check for startTime - if (Activity.getStartTime() == null) { - logger.warn("Skipping Activity " + Activity.getId() + " - null startTime"); - continue; - } - - // Convert to UTC-based LocalDate for consistent date filtering - LocalDate ActivityDate = Activity.getStartTime().atZoneSameInstant(java.time.ZoneId.of("UTC")).toLocalDate(); - - // Apply date filtering if specified - if (isDateInRange(ActivityDate, startDate, endDate)) { - logger.info("Activity " + Activity.getId() + " is in date range, creating CalendarActivityDTO"); - activities.add(createCalendarActivityFromActivity(Activity, userId, "creator")); - logger.info("Successfully created CalendarActivityDTO for Activity: " + Activity.getId()); - } else { - logger.info("Activity " + Activity.getId() + " is outside date range, skipping"); - } - } catch (Exception e) { - logger.error("Error processing created Activity: " + - (Activity != null ? Activity.getId() : "null Activity") + " for user: " + userId + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - // Continue processing other Activities - } - } - logger.info("Finished processing created Activities. Current activities list size: " + activities.size()); - - // Process Activities the user is participating in - logger.info("Starting to process " + participatingActivities.size() + " participating Activities for user: " + userId); - for (ActivityUser ActivityUser : participatingActivities) { - try { - logger.info("Processing participating ActivityUser for user: " + userId); - Activity Activity = ActivityUser.getActivity(); - - // Add null safety checks - if (Activity == null) { - logger.warn("Skipping ActivityUser - null Activity"); - continue; - } - if (Activity.getStartTime() == null) { - logger.warn("Skipping Activity " + Activity.getId() + " - null startTime"); - continue; - } - - logger.info("Got Activity " + Activity.getId() + " from ActivityUser for user: " + userId); - - // Convert to UTC-based LocalDate for consistent date filtering - LocalDate ActivityDate = Activity.getStartTime().atZoneSameInstant(java.time.ZoneId.of("UTC")).toLocalDate(); - - // Apply date filtering if specified - if (isDateInRange(ActivityDate, startDate, endDate)) { - // Avoid adding duplicate entries for Activities the user both created and is participating in - if (!Activity.getCreator().getId().equals(userId)) { - logger.info("Activity " + Activity.getId() + " is not created by user, adding as participant"); - activities.add(createCalendarActivityFromActivity(Activity, userId, "participant")); - logger.info("Successfully created CalendarActivityDTO for participating Activity: " + Activity.getId()); - } else { - logger.info("Activity " + Activity.getId() + " was created by user, skipping to avoid duplicate"); - } - } else { - logger.info("Activity " + Activity.getId() + " is outside date range, skipping"); - } - } catch (Exception e) { - logger.error("Error processing participating Activity: " + - (ActivityUser != null && ActivityUser.getActivity() != null ? ActivityUser.getActivity().getId() : "null") + - " for user: " + userId + ". Error: " + e.getMessage() + - ", Stack trace: " + Arrays.toString(e.getStackTrace())); - // Continue processing other Activities - } - } - logger.info("Finished processing participating Activities. Final activities list size: " + activities.size()); - - return activities; - - } catch (Exception e) { - logger.error("Error fetching calendar activities for user: " + userId + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } - - /** - * Clear the calendar cache for a specific user - * This should be called when Activities are created, updated, or deleted, - * or when a user's participation status changes. - */ - public void clearCalendarCache(UUID userId) { - logger.info("Clearing calendar cache for user: " + userId); - - // Clear the cache for all calendar activities for the specific user - cacheEvictionHelper.evictCache(CacheNames.ALL_CALENDAR_ACTIVITIES, userId); - - // Clear the filtered and monthly calendar caches for all users - // (since one user's changes might affect other users' filtered views) - cacheEvictionHelper.clearCaches( - CacheNames.FILTERED_CALENDAR_ACTIVITIES, - CacheNames.CALENDAR_ACTIVITIES - ); - } - - /** - * Clear all calendar caches for all users - * This should be called after schema changes or major updates to ensure fresh data - */ - public void clearAllCalendarCaches() { - logger.info("Clearing ALL calendar caches for all users"); - cacheEvictionHelper.clearAllCalendarCaches(); - } - - /** - * Check if a date falls within the specified range - * - * @param date The date to check - * @param startDate Start of the range (inclusive), or null for no lower bound - * @param endDate End of the range (inclusive), or null for no upper bound - * @return true if the date is within the range, false otherwise - */ - private boolean isDateInRange(LocalDate date, LocalDate startDate, LocalDate endDate) { - try { - // Add null safety for date parameter - if (date == null) { - logger.warn("isDateInRange called with null date parameter"); - return false; - } - - // If no date range is specified, include all dates - if (startDate == null && endDate == null) { - logger.info("No date range specified, including date: " + date); - return true; - } - - // Check lower bound if specified - boolean afterStart = (startDate == null || !date.isBefore(startDate)); - - // Check upper bound if specified - boolean beforeEnd = (endDate == null || !date.isAfter(endDate)); - - boolean inRange = afterStart && beforeEnd; - - logger.info("Date range check for " + date + - " (start: " + startDate + ", end: " + endDate + "): " + - (inRange ? "INCLUDED" : "EXCLUDED") + - " (afterStart: " + afterStart + ", beforeEnd: " + beforeEnd + ")"); - - return inRange; - } catch (Exception e) { - logger.error("Error checking if date is in range. Date: " + date + - ", startDate: " + startDate + ", endDate: " + endDate + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } - - /** - * Create a CalendarActivityDTO from an Activity - */ - private CalendarActivityDTO createCalendarActivityFromActivity(Activity Activity, UUID userId, String role) { - try { - // Add null safety checks - if (Activity == null) { - throw new IllegalArgumentException("Activity cannot be null"); - } - if (Activity.getId() == null) { - throw new IllegalArgumentException("Activity ID cannot be null"); - } - if (Activity.getStartTime() == null) { - throw new IllegalArgumentException("Activity start time cannot be null for Activity ID: " + Activity.getId()); - } - - // Use UTC timezone for consistent date formatting across different server timezones - LocalDate activityDate = Activity.getStartTime().atZoneSameInstant(java.time.ZoneId.of("UTC")).toLocalDate(); - String formattedDate = activityDate.format(DATE_FORMATTER); - - logger.info("Creating CalendarActivityDTO for Activity: " + Activity.getId() + - ", StartTime: " + Activity.getStartTime() + - ", Formatted Date: " + formattedDate + - ", Role: " + role); - - CalendarActivityDTO calendarActivityDTO = CalendarActivityDTO.builder() - .id(Activity.getId()) - .date(formattedDate) - .title(Activity.getTitle() != null ? Activity.getTitle() : "Untitled Activity") - .icon(Activity.getIcon() != null ? Activity.getIcon() : "⭐") - .colorHexCode(Activity.getColorHexCode() != null ? Activity.getColorHexCode() : "#6B73FF") - .activityId(Activity.getId()) - .build(); - - logger.info("Successfully created CalendarActivityDTO: " + calendarActivityDTO.getId() + - ", Date: " + calendarActivityDTO.getDate() + - ", Title: " + calendarActivityDTO.getTitle()); - - return calendarActivityDTO; - } catch (Exception e) { - logger.error("Error creating calendar activity from Activity: " + - (Activity != null ? Activity.getId() : "null Activity") + - " for user: " + userId + ", role: " + role + - ". Error: " + e.getMessage() + ", Stack trace: " + Arrays.toString(e.getStackTrace())); - throw e; - } - } -} \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ChatQueryService.java b/src/main/java/com/danielagapov/spawn/activity/internal/services/ChatQueryService.java deleted file mode 100644 index a6e436ce3..000000000 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/ChatQueryService.java +++ /dev/null @@ -1,213 +0,0 @@ -package com.danielagapov.spawn.activity.internal.services; - -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.danielagapov.spawn.shared.events.ChatEvents.*; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; -import java.util.stream.Collectors; - -/** - * Service for querying chat data from the Chat module via events. - * This replaces the direct dependency on IChatMessageService in ActivityService, - * breaking the circular dependency between Activity and Chat modules. - */ -@Service -public class ChatQueryService implements IChatQueryService { - - private static final long QUERY_TIMEOUT_MS = 5000; // 5 second timeout - - private final ApplicationEventPublisher eventPublisher; - private final IUserService userService; - private final ILogger logger; - - // Pending query futures for async response matching - private final ConcurrentHashMap>> pendingIdQueries = new ConcurrentHashMap<>(); - private final ConcurrentHashMap>> pendingBatchIdQueries = new ConcurrentHashMap<>(); - private final ConcurrentHashMap>> pendingFullMessageQueries = new ConcurrentHashMap<>(); - - public ChatQueryService( - ApplicationEventPublisher eventPublisher, - IUserService userService, - ILogger logger) { - this.eventPublisher = eventPublisher; - this.userService = userService; - this.logger = logger; - } - - /** - * Get chat message IDs for a single activity via event query. - */ - public List getChatMessageIdsByActivityId(UUID activityId) { - UUID requestId = UUID.randomUUID(); - CompletableFuture> future = new CompletableFuture<>(); - - pendingIdQueries.put(requestId, future); - - try { - // Publish query event - eventPublisher.publishEvent(new GetChatMessageIdsQuery(activityId, requestId)); - - // Wait for response with timeout - return future.get(QUERY_TIMEOUT_MS, TimeUnit.MILLISECONDS); - } catch (TimeoutException e) { - logger.warn("Timeout waiting for chat message IDs for activity " + activityId); - return Collections.emptyList(); - } catch (Exception e) { - logger.error("Error getting chat message IDs for activity " + activityId + ": " + e.getMessage()); - return Collections.emptyList(); - } finally { - pendingIdQueries.remove(requestId); - } - } - - /** - * Batch get chat message IDs for multiple activities via event query. - */ - public Map> getChatMessageIdsByActivityIds(List activityIds) { - if (activityIds.isEmpty()) { - return Collections.emptyMap(); - } - - UUID requestId = UUID.randomUUID(); - CompletableFuture> future = new CompletableFuture<>(); - - pendingBatchIdQueries.put(requestId, future); - - try { - // Publish query event - eventPublisher.publishEvent(new GetBatchChatMessageIdsQuery(activityIds, requestId)); - - // Wait for response with timeout - List results = future.get(QUERY_TIMEOUT_MS, TimeUnit.MILLISECONDS); - - // Convert to map - return results.stream() - .collect(Collectors.toMap( - ActivityMessageIds::activityId, - ActivityMessageIds::messageIds - )); - } catch (TimeoutException e) { - logger.warn("Timeout waiting for batch chat message IDs for " + activityIds.size() + " activities"); - return Collections.emptyMap(); - } catch (Exception e) { - logger.error("Error getting batch chat message IDs: " + e.getMessage()); - return Collections.emptyMap(); - } finally { - pendingBatchIdQueries.remove(requestId); - } - } - - /** - * Get full chat messages for an activity via event query. - * Converts ChatMessageData to FullActivityChatMessageDTO with user lookups. - */ - public List getFullChatMessagesByActivityId(UUID activityId) { - UUID requestId = UUID.randomUUID(); - CompletableFuture> future = new CompletableFuture<>(); - - pendingFullMessageQueries.put(requestId, future); - - try { - // Publish query event - eventPublisher.publishEvent(new GetFullChatMessagesQuery(activityId, requestId)); - - // Wait for response with timeout - List messageDataList = future.get(QUERY_TIMEOUT_MS, TimeUnit.MILLISECONDS); - - // Convert to FullActivityChatMessageDTO with user lookups - return messageDataList.stream() - .map(this::convertToFullActivityChatMessageDTO) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - } catch (TimeoutException e) { - logger.warn("Timeout waiting for full chat messages for activity " + activityId); - return Collections.emptyList(); - } catch (Exception e) { - logger.error("Error getting full chat messages for activity " + activityId + ": " + e.getMessage()); - return Collections.emptyList(); - } finally { - pendingFullMessageQueries.remove(requestId); - } - } - - /** - * Converts ChatMessageData to FullActivityChatMessageDTO by looking up user details. - */ - private FullActivityChatMessageDTO convertToFullActivityChatMessageDTO(ChatMessageData data) { - try { - // Look up sender user - BaseUserDTO senderUser = userService.getBaseUserById(data.senderUserId()); - - // Look up liked by users - List likedByUsers = data.likedByUserIds().stream() - .map(userId -> { - try { - return userService.getBaseUserById(userId); - } catch (Exception e) { - logger.warn("Could not load user " + userId + " for chat message like"); - return null; - } - }) - .filter(Objects::nonNull) - .collect(Collectors.toList()); - - return new FullActivityChatMessageDTO( - data.id(), - data.content(), - data.timestamp(), - senderUser, - data.activityId(), - likedByUsers - ); - } catch (Exception e) { - logger.warn("Error converting chat message data to DTO: " + e.getMessage()); - return null; - } - } - - // ========== Event Listeners for Response Handling ========== - - /** - * Handle response for single activity chat message ID query. - */ - @EventListener - public void handleChatMessageIdsResponse(ChatMessageIdsResponse response) { - CompletableFuture> future = pendingIdQueries.get(response.requestId()); - if (future != null) { - future.complete(response.messageIds()); - } - } - - /** - * Handle response for batch chat message ID query. - */ - @EventListener - public void handleBatchChatMessageIdsResponse(BatchChatMessageIdsResponse response) { - CompletableFuture> future = pendingBatchIdQueries.get(response.requestId()); - if (future != null) { - future.complete(response.activityMessageIds()); - } - } - - /** - * Handle response for full chat messages query. - */ - @EventListener - public void handleFullChatMessagesResponse(FullChatMessagesResponse response) { - CompletableFuture> future = pendingFullMessageQueries.get(response.requestId()); - if (future != null) { - future.complete(response.messages()); - } - } -} - diff --git a/src/main/java/com/danielagapov/spawn/analytics/api/ShareLinkController.java b/src/main/java/com/danielagapov/spawn/analytics/api/ShareLinkController.java index f31ba4f61..c93293ae7 100644 --- a/src/main/java/com/danielagapov/spawn/analytics/api/ShareLinkController.java +++ b/src/main/java/com/danielagapov/spawn/analytics/api/ShareLinkController.java @@ -4,7 +4,7 @@ import com.danielagapov.spawn.user.api.dto.BaseUserDTO; import com.danielagapov.spawn.shared.util.ShareLinkType; import com.danielagapov.spawn.analytics.internal.domain.ShareLink; -import com.danielagapov.spawn.activity.internal.services.ActivityService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.analytics.internal.services.ShareLinkService; import com.danielagapov.spawn.user.internal.services.UserService; import lombok.RequiredArgsConstructor; @@ -28,7 +28,7 @@ public class ShareLinkController { private final ShareLinkService shareLinkService; - private final ActivityService activityService; + private final ActivityServiceClient activityServiceClient; private final UserService userService; /** @@ -40,7 +40,7 @@ public class ShareLinkController { public ResponseEntity> generateActivityShareCode(@PathVariable UUID activityId) { try { // Get the activity DTO to access start and end times - com.danielagapov.spawn.activity.api.dto.ActivityDTO activity = activityService.getActivityById(activityId); + FullFeedActivityDTO activity = activityServiceClient.getFullActivityById(activityId, null); if (activity == null) { return ResponseEntity.notFound().build(); } @@ -104,7 +104,7 @@ public ResponseEntity resolveActivityShareCode(@PathVariabl } // Get the activity details - FullFeedActivityDTO activity = activityService.getFullActivityById(link.getTargetId(), null); + FullFeedActivityDTO activity = activityServiceClient.getFullActivityById(link.getTargetId(), null); if (activity == null) { // Activity was deleted, clean up the share link shareLinkService.deleteShareLinksForTarget(link.getTargetId(), ShareLinkType.ACTIVITY); diff --git a/src/main/java/com/danielagapov/spawn/analytics/internal/services/CacheService.java b/src/main/java/com/danielagapov/spawn/analytics/internal/services/CacheService.java index da6323f1d..0169f2985 100644 --- a/src/main/java/com/danielagapov/spawn/analytics/internal/services/CacheService.java +++ b/src/main/java/com/danielagapov/spawn/analytics/internal/services/CacheService.java @@ -2,10 +2,9 @@ import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; import com.danielagapov.spawn.shared.config.CacheValidationResponseDTO; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.domain.User; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.activity.api.IActivityService; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; import com.danielagapov.spawn.social.internal.services.IFriendRequestService; import com.danielagapov.spawn.user.internal.services.IUserService; import com.danielagapov.spawn.user.internal.services.IUserInterestService; @@ -41,8 +40,7 @@ public class CacheService implements ICacheService { private static final Logger logger = LoggerFactory.getLogger(CacheService.class); private final IUserRepository userRepository; private final IUserService userService; - private final IActivityService ActivityService; - private final IActivityTypeService activityTypeService; + private final ActivityServiceClient activityServiceClient; private final IFriendRequestService friendRequestService; private final ObjectMapper objectMapper; private final IUserStatsService userStatsService; @@ -55,8 +53,7 @@ public class CacheService implements ICacheService { public CacheService( IUserRepository userRepository, IUserService userService, - IActivityService ActivityService, - IActivityTypeService activityTypeService, + ActivityServiceClient activityServiceClient, IFriendRequestService friendRequestService, ObjectMapper objectMapper, IUserStatsService userStatsService, @@ -66,8 +63,7 @@ public CacheService( IRecentlySpawnedService recentlySpawnedService) { this.userRepository = userRepository; this.userService = userService; - this.ActivityService = ActivityService; - this.activityTypeService = activityTypeService; + this.activityServiceClient = activityServiceClient; this.friendRequestService = friendRequestService; this.objectMapper = objectMapper; this.userStatsService = userStatsService; @@ -350,7 +346,7 @@ private CacheValidationResponseDTO validateEventsCache(User user, String clientT clientTimestamp, CacheType.EVENTS, () -> getLatestActivityActivity(user.getId()), - () -> ActivityService.getFeedActivities(user.getId()) + () -> activityServiceClient.getFeedActivities(user.getId()) ); } @@ -366,7 +362,7 @@ private CacheValidationResponseDTO validateActivityTypesCache(User user, String clientTimestamp, CacheType.ACTIVITY_TYPES, () -> getLatestActivityTypeUpdate(user.getId()), - () -> activityTypeService.getActivityTypesByUserId(user.getId()) + () -> activityServiceClient.getActivityTypesByUserId(user.getId()) ); } @@ -495,7 +491,7 @@ private CacheValidationResponseDTO validateProfileEventsCache(User user, String clientTimestamp, CacheType.PROFILE_EVENTS, () -> getLatestActivityActivity(user.getId()), - () -> ActivityService.getProfileActivities(user.getId(), user.getId()) + () -> activityServiceClient.getProfileActivities(user.getId(), user.getId()) ); } @@ -548,13 +544,13 @@ private Instant getLatestFriendActivity(UUID userId) { private Instant getLatestActivityActivity(UUID userId) { try { // Get the latest activity created by the user - Instant latestCreatedActivity = ActivityService.getLatestCreatedActivityTimestamp(userId); + Instant latestCreatedActivity = activityServiceClient.getLatestCreatedActivityTimestamp(userId); // Get the latest activity the user was invited to - Instant latestInvitedActivity = ActivityService.getLatestInvitedActivityTimestamp(userId); + Instant latestInvitedActivity = activityServiceClient.getLatestInvitedActivityTimestamp(userId); // Get the latest activity the user is participating in that was updated - Instant latestUpdatedActivity = ActivityService.getLatestUpdatedActivityTimestamp(userId); + Instant latestUpdatedActivity = activityServiceClient.getLatestUpdatedActivityTimestamp(userId); // Find the most recent timestamp among these three Instant latestTimestamp = null; diff --git a/src/main/java/com/danielagapov/spawn/analytics/internal/services/ReportContentService.java b/src/main/java/com/danielagapov/spawn/analytics/internal/services/ReportContentService.java index f7f57371d..c204a3b71 100644 --- a/src/main/java/com/danielagapov/spawn/analytics/internal/services/ReportContentService.java +++ b/src/main/java/com/danielagapov/spawn/analytics/internal/services/ReportContentService.java @@ -10,8 +10,8 @@ import com.danielagapov.spawn.analytics.internal.domain.ReportedContent; import com.danielagapov.spawn.user.internal.domain.User; import com.danielagapov.spawn.analytics.internal.repositories.IReportedContentRepository; -import com.danielagapov.spawn.chat.internal.services.IChatMessageService; -import com.danielagapov.spawn.activity.api.IActivityService; +import com.danielagapov.spawn.shared.feign.ChatServiceClient; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.services.IUserService; import com.danielagapov.spawn.shared.exceptions.Logger.Logger; import lombok.AllArgsConstructor; @@ -29,8 +29,8 @@ public class ReportContentService implements IReportContentService { private final IReportedContentRepository repository; private final IUserService userService; - private final IActivityService ActivityService; - private final IChatMessageService chatMessageService; + private final ActivityServiceClient activityServiceClient; + private final ChatServiceClient chatServiceClient; private final Logger logger; @@ -173,7 +173,7 @@ private User findContentOwnerByContentId(UUID contentId, EntityType contentType) * Made a wrapper method for improved readability in the caller method. */ private User getActivityOwnerByContentId(UUID activityId) { - return userService.getUserEntityById(ActivityService.getActivityById(activityId).getCreatorUserId()); + return userService.getUserEntityById(activityServiceClient.getCreatorId(activityId)); } /** @@ -181,6 +181,6 @@ private User getActivityOwnerByContentId(UUID activityId) { * Made a wrapper method for improved readability in the caller method. */ private User getChatMessageOwnerByContentId(UUID chatMessageId) { - return userService.getUserEntityById(chatMessageService.getChatMessageById(chatMessageId).getSenderUserId()); + return userService.getUserEntityById(chatServiceClient.getChatMessageById(chatMessageId).getSenderUserId()); } } diff --git a/src/main/java/com/danielagapov/spawn/analytics/internal/services/ShareLinkService.java b/src/main/java/com/danielagapov/spawn/analytics/internal/services/ShareLinkService.java index 74870fa5d..5b47dd59c 100644 --- a/src/main/java/com/danielagapov/spawn/analytics/internal/services/ShareLinkService.java +++ b/src/main/java/com/danielagapov/spawn/analytics/internal/services/ShareLinkService.java @@ -3,7 +3,7 @@ import com.danielagapov.spawn.shared.util.ShareLinkType; import com.danielagapov.spawn.analytics.internal.domain.ShareLink; import com.danielagapov.spawn.analytics.internal.repositories.ShareLinkRepository; -import com.danielagapov.spawn.activity.internal.services.ActivityExpirationService; +import com.danielagapov.spawn.shared.util.ActivityExpirationUtil; import com.danielagapov.spawn.shared.util.ShareCodeGenerator; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -11,6 +11,7 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Instant; +import java.time.OffsetDateTime; import java.util.Optional; import java.util.UUID; @@ -25,7 +26,6 @@ public class ShareLinkService { private final ShareLinkRepository shareLinkRepository; private final ShareCodeGenerator shareCodeGenerator; - private final ActivityExpirationService expirationService; private static final int MAX_GENERATION_RETRIES = 10; @@ -40,8 +40,9 @@ public class ShareLinkService { @Transactional public String generateActivityShareLink(UUID activityId, java.time.OffsetDateTime startTime, java.time.OffsetDateTime endTime, Instant createdAt) { Instant shareExpiration = null; - if (expirationService.calculateShareLinkExpiration(startTime, endTime, createdAt) != null) { - shareExpiration = expirationService.calculateShareLinkExpiration(startTime, endTime, createdAt).toInstant(); + OffsetDateTime expiration = ActivityExpirationUtil.calculateShareLinkExpiration(startTime, endTime, createdAt); + if (expiration != null) { + shareExpiration = expiration.toInstant(); } return generateShareLink(activityId, ShareLinkType.ACTIVITY, shareExpiration); } diff --git a/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java b/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java deleted file mode 100644 index 1dd05b937..000000000 --- a/src/main/java/com/danielagapov/spawn/chat/api/ChatMessageController.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.danielagapov.spawn.chat.api; - -import com.danielagapov.spawn.chat.api.dto.AbstractChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; -import com.danielagapov.spawn.chat.api.dto.CreateChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Base.BasesNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.chat.internal.services.IChatMessageService; -import com.danielagapov.spawn.shared.util.LoggingUtils; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -@RestController() -@RequestMapping("api/v1/chat-messages") -public class ChatMessageController { - private final IChatMessageService chatMessageService; - private final ILogger logger; - - @Autowired - public ChatMessageController(IChatMessageService chatMessageService, ILogger logger) { - this.chatMessageService = chatMessageService; - this.logger = logger; - } - - // full path: /api/v1/chat-messages - @PostMapping - public ResponseEntity createChatMessage(@RequestBody CreateChatMessageDTO newChatMessage) { - try { - FullActivityChatMessageDTO createdMessage = chatMessageService.createChatMessage(newChatMessage); - return new ResponseEntity<>(createdMessage, HttpStatus.CREATED); - } catch (Exception e) { - logger.error("Error creating chat message: " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - - // TL;DR: Don't remove this endpoint; it may become useful. - @Deprecated(since = "Not being used on mobile currently. " + - "Pending mobile feature implementation, per:" + - "https://github.com/Daggerpov/Spawn-App-iOS-SwiftUI/issues/142") - // full path: /api/v1/chat-messages/{id} - @DeleteMapping("/{id}") - public ResponseEntity deleteChatMessage(@PathVariable UUID id) { - if (id == null) { - logger.error("Invalid parameter: chat message ID is null"); - return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - } - try { - boolean isDeleted = chatMessageService.deleteChatMessageById(id); - if (isDeleted) { - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } else { - logger.error("Failed to delete chat message: " + id); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } catch (BaseNotFoundException e) { - logger.error("Chat message not found for deletion: " + id + ": " + e.getMessage()); - return new ResponseEntity<>(null, HttpStatus.NOT_FOUND); - } catch (Exception e) { - logger.error("Error deleting chat message: " + id + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - - // TL;DR: Don't remove this endpoint; it may become useful. - @Deprecated(since = "Not being used on mobile currently. " + - "Pending mobile feature implementation, per:" + - "https://github.com/Daggerpov/Spawn-App-iOS-SwiftUI/issues/142") - // full path: /api/v1/chat-messages/{chatMessageId}/likes/{userId} - @PostMapping("/{chatMessageId}/likes/{userId}") - public ResponseEntity createChatMessageLike(@PathVariable UUID chatMessageId, @PathVariable UUID userId) { - if (chatMessageId == null || userId == null) { - logger.error("Invalid parameters: chatMessageId or userId is null"); - return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - } - try { - ChatMessageLikesDTO createdLike = chatMessageService.createChatMessageLike(chatMessageId, userId); - return new ResponseEntity<>(createdLike, HttpStatus.CREATED); - } catch (BaseNotFoundException e) { - logger.error("Chat message or user not found for like creation: " + e.getMessage()); - return new ResponseEntity(HttpStatus.NOT_FOUND); - } catch (Exception e) { - logger.error("Error creating chat message like for message: " + chatMessageId + " by user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - // TL;DR: Don't remove this endpoint; it may become useful. - @Deprecated(since = "Not being used on mobile currently. " + - "Pending mobile feature implementation, per:" + - "https://github.com/Daggerpov/Spawn-App-iOS-SwiftUI/issues/142") - // full path: /api/v1/chat-messages/{chatMessageId}/likes - @GetMapping("/{chatMessageId}/likes") - public ResponseEntity> getChatMessageLikes(@PathVariable UUID chatMessageId) { - if (chatMessageId == null) { - logger.error("Invalid parameter: chatMessageId is null"); - return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - } - try { - return new ResponseEntity<>(chatMessageService.getChatMessageLikes(chatMessageId), HttpStatus.OK); - } catch (BaseNotFoundException e) { - logger.error("Chat message not found for likes retrieval: " + chatMessageId + ": " + e.getMessage()); - return new ResponseEntity>(HttpStatus.NOT_FOUND); - } catch (Exception e) { - logger.error("Error getting chat message likes for message: " + chatMessageId + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } - - - - // TL;DR: Don't remove this endpoint; it may become useful. - @Deprecated(since = "Not being used on mobile currently. " + - "Pending mobile feature implementation, per:" + - "https://github.com/Daggerpov/Spawn-App-iOS-SwiftUI/issues/142") - // full path: /api/v1/chat-messages/{chatMessageId}/likes/{userId} - @DeleteMapping("/{chatMessageId}/likes/{userId}") - public ResponseEntity deleteChatMessageLike(@PathVariable UUID chatMessageId, @PathVariable UUID userId) { - if (chatMessageId == null || userId == null) { - logger.error("Invalid parameters: chatMessageId or userId is null"); - return new ResponseEntity<>(HttpStatus.BAD_REQUEST); - } - try { - chatMessageService.deleteChatMessageLike(chatMessageId, userId); - return new ResponseEntity<>(HttpStatus.NO_CONTENT); - } catch (BaseNotFoundException e) { - logger.error("Chat message like not found for deletion: " + e.getMessage()); - return new ResponseEntity<>(e.entityType, HttpStatus.NOT_FOUND); - } catch (Exception e) { - logger.error("Error deleting chat message like for message: " + chatMessageId + " by user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); - } - } -} diff --git a/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatEventListener.java b/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatEventListener.java deleted file mode 100644 index 6844a28df..000000000 --- a/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatEventListener.java +++ /dev/null @@ -1,151 +0,0 @@ -package com.danielagapov.spawn.chat.internal.services; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.shared.events.ChatEvents.*; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; - -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -/** - * Event listener for Chat module. - * Handles query events from other modules (primarily Activity) and publishes responses. - * This breaks the circular dependency between Activity and Chat modules. - */ -@Service -public class ChatEventListener { - - private final IChatMessageService chatMessageService; - private final ApplicationEventPublisher eventPublisher; - private final ILogger logger; - - public ChatEventListener( - IChatMessageService chatMessageService, - ApplicationEventPublisher eventPublisher, - ILogger logger) { - this.chatMessageService = chatMessageService; - this.eventPublisher = eventPublisher; - this.logger = logger; - } - - /** - * Handles query for chat message IDs for a single activity. - * Publishes ChatMessageIdsResponse with the results. - */ - @EventListener - public void handleGetChatMessageIdsQuery(GetChatMessageIdsQuery query) { - try { - logger.info("Handling GetChatMessageIdsQuery for activity: " + query.activityId()); - - List messageIds = chatMessageService.getChatMessageIdsByActivityId(query.activityId()); - - eventPublisher.publishEvent(new ChatMessageIdsResponse( - query.activityId(), - query.requestId(), - messageIds - )); - - logger.info("Published ChatMessageIdsResponse with " + messageIds.size() + " message IDs"); - } catch (Exception e) { - logger.error("Error handling GetChatMessageIdsQuery: " + e.getMessage()); - // Publish empty response on error to prevent blocking - eventPublisher.publishEvent(new ChatMessageIdsResponse( - query.activityId(), - query.requestId(), - List.of() - )); - } - } - - /** - * Handles batch query for chat message IDs for multiple activities. - * Publishes BatchChatMessageIdsResponse with the results. - */ - @EventListener - public void handleGetBatchChatMessageIdsQuery(GetBatchChatMessageIdsQuery query) { - try { - logger.info("Handling GetBatchChatMessageIdsQuery for " + query.activityIds().size() + " activities"); - - List results = chatMessageService.getChatMessageIdsByActivityIds(query.activityIds()); - - // Group results by activity ID - Map> groupedResults = results.stream() - .collect(Collectors.groupingBy( - row -> (UUID) row[0], - Collectors.mapping( - row -> (UUID) row[1], - Collectors.toList() - ) - )); - - // Convert to response format - List activityMessageIds = query.activityIds().stream() - .map(activityId -> new ActivityMessageIds( - activityId, - groupedResults.getOrDefault(activityId, List.of()) - )) - .collect(Collectors.toList()); - - eventPublisher.publishEvent(new BatchChatMessageIdsResponse( - query.requestId(), - activityMessageIds - )); - - logger.info("Published BatchChatMessageIdsResponse for " + activityMessageIds.size() + " activities"); - } catch (Exception e) { - logger.error("Error handling GetBatchChatMessageIdsQuery: " + e.getMessage()); - // Publish empty response on error - eventPublisher.publishEvent(new BatchChatMessageIdsResponse( - query.requestId(), - List.of() - )); - } - } - - /** - * Handles query for full chat messages for an activity. - * Publishes FullChatMessagesResponse with the results. - */ - @EventListener - public void handleGetFullChatMessagesQuery(GetFullChatMessagesQuery query) { - try { - logger.info("Handling GetFullChatMessagesQuery for activity: " + query.activityId()); - - List messages = chatMessageService.getChatMessagesByActivityId(query.activityId()); - - // Convert to event data format - List messageData = messages.stream() - .map(msg -> new ChatMessageData( - msg.getId(), - msg.getContent(), - msg.getTimestamp(), - msg.getSenderUserId(), - msg.getActivityId(), - msg.getLikedByUserIds() - )) - .collect(Collectors.toList()); - - eventPublisher.publishEvent(new FullChatMessagesResponse( - query.activityId(), - query.requestId(), - messageData - )); - - logger.info("Published FullChatMessagesResponse with " + messageData.size() + " messages"); - } catch (Exception e) { - logger.error("Error handling GetFullChatMessagesQuery: " + e.getMessage()); - // Publish empty response on error - eventPublisher.publishEvent(new FullChatMessagesResponse( - query.activityId(), - query.requestId(), - List.of() - )); - } - } -} - diff --git a/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java b/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java deleted file mode 100644 index 11626e0fc..000000000 --- a/src/main/java/com/danielagapov/spawn/chat/internal/services/ChatMessageService.java +++ /dev/null @@ -1,358 +0,0 @@ -package com.danielagapov.spawn.chat.internal.services; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; -import com.danielagapov.spawn.chat.api.dto.CreateChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.shared.events.NewCommentNotificationEvent; -import com.danielagapov.spawn.shared.exceptions.Base.BaseDeleteException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; -import com.danielagapov.spawn.shared.exceptions.Base.BasesNotFoundException; -import com.danielagapov.spawn.shared.exceptions.EntityAlreadyExistsException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.util.ChatMessageLikesMapper; -import com.danielagapov.spawn.shared.util.ChatMessageMapper; -import com.danielagapov.spawn.shared.util.ParticipationStatus; -import com.danielagapov.spawn.shared.util.UserMapper; -import com.danielagapov.spawn.activity.api.IActivityService; -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikes; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikesId; -import com.danielagapov.spawn.activity.internal.repositories.IActivityRepository; -import com.danielagapov.spawn.activity.internal.repositories.IChatMessageLikesRepository; -import com.danielagapov.spawn.activity.internal.repositories.IChatMessageRepository; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.springframework.cache.annotation.CacheEvict; -import org.springframework.cache.annotation.Caching; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataAccessException; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -@Service -public class ChatMessageService implements IChatMessageService { - private final IChatMessageRepository chatMessageRepository; - private final IUserService userService; - private final IUserRepository userRepository; - private final IChatMessageLikesRepository chatMessageLikesRepository; - private final ILogger logger; - private final IActivityService activityService; - private final ApplicationEventPublisher eventPublisher; - private final IActivityRepository activityRepository; - - public ChatMessageService(IChatMessageRepository chatMessageRepository, IUserService userService, - IChatMessageLikesRepository chatMessageLikesRepository, - IUserRepository userRepository, ILogger logger, - IActivityService activityService, - ApplicationEventPublisher eventPublisher, - IActivityRepository activityRepository) { - this.chatMessageRepository = chatMessageRepository; - this.userService = userService; - this.chatMessageLikesRepository = chatMessageLikesRepository; - this.userRepository = userRepository; - this.logger = logger; - this.activityService = activityService; - this.eventPublisher = eventPublisher; - this.activityRepository = activityRepository; - } - - @Override - public List getAllChatMessages() { - try { - List chatMessages = chatMessageRepository.findAll(); - - Map> likedByMap = chatMessages.stream() - .collect(Collectors.toMap( - chatMessage -> chatMessage, - chatMessage -> getChatMessageLikeUserIds(chatMessage.getId()) - )); - - // Return the mapped DTOs including the sender and likedByUserIds - return ChatMessageMapper.toDTOList(chatMessages, likedByMap); - } catch (DataAccessException e) { - logger.error(e.getMessage()); - throw new BasesNotFoundException(EntityType.ChatMessage); - } catch (Exception e) { - logger.error(e.getMessage()); - throw e; - } - } - - @Override - public List getChatMessageLikeUserIds(UUID chatMessageId) { - // Retrieve the ChatMessage by its ID - ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) - .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); - - // Retrieve all the likes for the given chat message - List likes = chatMessageLikesRepository.findByChatMessage(chatMessage); - - // Extract the user IDs of the users who liked the chat message - return likes.stream() - .map(like -> like.getUser().getId()) // Extract the user ID from each like - .collect(Collectors.toList()); // Collect them into a list - } - - - @Override - public ChatMessageDTO getChatMessageById(UUID id) { - return chatMessageRepository.findById(id) - .map(chatMessage -> { - List likedByUserIds = getChatMessageLikeUserIds(chatMessage.getId()); - return ChatMessageMapper.toDTO(chatMessage, likedByUserIds); - }) - .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, id)); - } - - @Override - @Caching(evict = { - @CacheEvict(value = "ActivityById", key = "#newChatMessageDTO.activityId"), - @CacheEvict(value = "fullActivityById", allEntries = true), - @CacheEvict(value = "feedActivities", allEntries = true) - }) - public FullActivityChatMessageDTO createChatMessage(CreateChatMessageDTO newChatMessageDTO) { - ChatMessageDTO chatMessageDTO = new ChatMessageDTO( - null, // Let Hibernate auto-generate the ID - newChatMessageDTO.getContent(), - Instant.now(), - newChatMessageDTO.getSenderUserId(), - newChatMessageDTO.getActivityId(), - List.of() - ); - - ChatMessageDTO savedMessage = saveChatMessage(chatMessageDTO); - - // Get the Activity title and creator details using the service API - UUID activityId = savedMessage.getActivityId(); - String activityTitle = activityService.getActivityTitle(activityId); - UUID activityCreatorId = activityService.getActivityCreatorId(activityId); - - if (activityTitle == null || activityCreatorId == null) { - throw new BaseNotFoundException(EntityType.Activity, activityId); - } - - User sender = userRepository.findById(savedMessage.getSenderUserId()) - .orElseThrow(() -> new BaseNotFoundException(EntityType.User, savedMessage.getSenderUserId())); - - // Get participant IDs using the public API (maintains module boundaries) - List participantIds = activityService.getParticipantUserIdsByActivityIdAndStatus( - activityId, ParticipationStatus.participating); - - // Create and publish notification event with participant IDs - eventPublisher.publishEvent(new NewCommentNotificationEvent( - sender.getId(), - sender.getUsername(), - activityId, - activityTitle, - activityCreatorId, - savedMessage, - participantIds)); - - // Convert to FullActivityChatMessageDTO before returning - return getFullChatMessageByChatMessage(savedMessage); - } - - @Override - public FullActivityChatMessageDTO getFullChatMessageById(UUID id) { - return getFullChatMessageByChatMessage(getChatMessageById(id)); - } - - @Override - public List getFullChatMessagesByActivityId(UUID activityId) { - ArrayList fullChatMessages = new ArrayList<>(); - for (ChatMessageDTO cm : getChatMessagesByActivityId(activityId)) { - fullChatMessages.add(getFullChatMessageByChatMessage(cm)); - } - return fullChatMessages; - } - - - @Override - public ChatMessageDTO saveChatMessage(ChatMessageDTO chatMessageDTO) { - try { - User userSender = userRepository.findById(chatMessageDTO.getSenderUserId()) - .orElseThrow(() -> new BaseNotFoundException(EntityType.User, chatMessageDTO.getSenderUserId())); - - // Fetch the activity from the repository (now in the same module as ChatMessage) - Activity activity = activityRepository.findById(chatMessageDTO.getActivityId()) - .orElseThrow(() -> new BaseNotFoundException(EntityType.Activity, chatMessageDTO.getActivityId())); - - ChatMessage chatMessageEntity = ChatMessageMapper.toEntity(chatMessageDTO, userSender, activity); - - ChatMessage savedEntity = chatMessageRepository.save(chatMessageEntity); - - return ChatMessageMapper.toDTO(savedEntity, List.of()); // Empty likedByUserIds list - } catch (DataAccessException e) { - logger.error(e.getMessage()); - throw new BaseSaveException("Failed to save chatMessage: " + e.getMessage()); - } catch (Exception e) { - logger.error(e.getMessage()); - throw e; - } - } - - @Override - public List getChatMessageIdsByActivityId(UUID activityId) { - try { - // Retrieve all chat messages for the specified Activity - List chatMessages = chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(activityId); - - // Extract the IDs of the chat messages and return them as a list - return chatMessages.stream() - .map(ChatMessage::getId) - .collect(Collectors.toList()); - } catch (DataAccessException e) { - logger.error(e.getMessage()); - throw new BaseNotFoundException(EntityType.ChatMessage, activityId); - } catch (Exception e) { - logger.error(e.getMessage()); - throw e; - } - } - - @Override - public List getChatMessageIdsByActivityIds(List activityIds) { - try { - if (activityIds.isEmpty()) { - return List.of(); - } - // Use the batch query from repository - return chatMessageRepository.findChatMessageIdsByActivityIds(activityIds); - } catch (DataAccessException e) { - logger.error("Error fetching chat message IDs for activities: " + e.getMessage()); - throw new BasesNotFoundException(EntityType.ChatMessage); - } catch (Exception e) { - logger.error("Error fetching chat message IDs for activities: " + e.getMessage()); - throw e; - } - } - - - @Override - public boolean deleteChatMessageById(UUID id) { - if (!chatMessageRepository.existsById(id)) { - throw new BaseNotFoundException(EntityType.ChatMessage, id); - } - - try { - chatMessageRepository.deleteById(id); - return true; - } catch (Exception e) { - logger.error(e.getMessage()); - return false; - } - } - - @Override - public ChatMessageLikesDTO createChatMessageLike(UUID chatMessageId, UUID userId) { - try { - boolean exists = chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId); - if (exists) { - throw new EntityAlreadyExistsException(EntityType.ChatMessage, chatMessageId); - } - ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) - .orElseThrow(() -> new BaseSaveException("ChatMessageId: " + chatMessageId)); - User user = userRepository.findById(userId) - .orElseThrow(() -> new BaseSaveException("UserId: " + userId)); - - // Create the composite key - ChatMessageLikesId id = new ChatMessageLikesId(chatMessageId, userId); - - ChatMessageLikes chatMessageLikes = new ChatMessageLikes(); - chatMessageLikes.setId(id); - chatMessageLikes.setChatMessage(chatMessage); - chatMessageLikes.setUser(user); - - chatMessageLikesRepository.save(chatMessageLikes); - return ChatMessageLikesMapper.toDTO(chatMessageLikes); - - } catch (Exception e) { - logger.error(e.getMessage()); - throw new BaseSaveException("Like: chatMessageId: " + chatMessageId + " userId: " - + userId + ". Error: " + e.getMessage()); - } - } - - @Override - public List getChatMessageLikes(UUID chatMessageId) { - ChatMessage chatMessage = chatMessageRepository.findById(chatMessageId) - .orElseThrow(() -> new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); - - List likes = chatMessageLikesRepository.findByChatMessage(chatMessage); - - return likes.stream() - .map(like -> { - List friendsUserIds = userService.getFriendUserIdsByUserId(like.getUser().getId()); - return UserMapper.toDTO(like.getUser(), friendsUserIds); - }) - .collect(Collectors.toList()); - } - - - @Override - public void deleteChatMessageLike(UUID chatMessageId, UUID userId) { - try { - boolean exists = chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId); - if (!exists) { - throw new BaseNotFoundException(EntityType.ChatMessage); - } - chatMessageLikesRepository.deleteByChatMessage_IdAndUser_Id(chatMessageId, userId); - } catch (Exception e) { - logger.error(e.getMessage()); - throw new BaseDeleteException("An error occurred while deleting the like for chatMessageId: " - + chatMessageId + " and userId: " + userId + ". Error: " + e.getMessage(), e); - } - } - - @Override - public List getChatMessagesByActivityId(UUID activityId) { - try { - List chatMessages = chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(activityId); - - return chatMessages.stream() - .map(chatMessage -> { - List likedByUserIds = getChatMessageLikeUserIds(chatMessage.getId()); - return ChatMessageMapper.toDTO(chatMessage, likedByUserIds); - }) - .collect(Collectors.toList()); - } catch (DataAccessException e) { - logger.error(e.getMessage()); - throw new BasesNotFoundException(EntityType.ChatMessage); - } catch (Exception e) { - logger.error(e.getMessage()); - throw e; - } - } - - @Override - public FullActivityChatMessageDTO getFullChatMessageByChatMessage(ChatMessageDTO chatMessage) { - return new FullActivityChatMessageDTO( - chatMessage.getId(), - chatMessage.getContent(), - chatMessage.getTimestamp(), - userService.getBaseUserById(chatMessage.getSenderUserId()), - chatMessage.getActivityId(), - getChatMessageLikes(chatMessage.getId()) - ); - } - - @Override - public List convertChatMessagesToFullFeedActivityChatMessages(List chatMessages) { - return chatMessages.stream() - .map(this::getFullChatMessageByChatMessage) - .collect(Collectors.toList()); - } - -} diff --git a/src/main/java/com/danielagapov/spawn/chat/internal/services/IChatMessageService.java b/src/main/java/com/danielagapov/spawn/chat/internal/services/IChatMessageService.java deleted file mode 100644 index 162234443..000000000 --- a/src/main/java/com/danielagapov/spawn/chat/internal/services/IChatMessageService.java +++ /dev/null @@ -1,163 +0,0 @@ -package com.danielagapov.spawn.chat.internal.services; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; -import com.danielagapov.spawn.chat.api.dto.CreateChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; - -import java.util.List; -import java.util.UUID; - -/** - * Service interface for managing chat messages and their interactions within activities. - * Provides CRUD operations for chat messages, likes management, and data conversion utilities. - */ -public interface IChatMessageService { - - /** - * Retrieves all chat messages from the database with their associated likes. - * - * @return List of ChatMessageDTO objects with liked by user IDs populated - * @throws com.danielagapov.spawn.Exceptions.Base.BasesNotFoundException if database access fails - */ - List getAllChatMessages(); - - /** - * Retrieves a specific chat message by its unique identifier. - * - * @param id the unique identifier of the chat message - * @return ChatMessageDTO object with liked by user IDs populated - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if chat message with given ID is not found - */ - ChatMessageDTO getChatMessageById(UUID id); - - /** - * Creates a new chat message from the provided DTO and publishes notification events. - * - * @param newChatMessageDTO the DTO containing chat message creation data - * @return the created ChatMessageDTO with generated ID and timestamp - * @throws com.danielagapov.spawn.Exceptions.Base.BaseSaveException if saving fails - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if referenced user or activity doesn't exist - */ - FullActivityChatMessageDTO createChatMessage(CreateChatMessageDTO newChatMessageDTO); - - /** - * Saves a chat message to the database. - * - * @param chatMessage the ChatMessageDTO to save - * @return the saved ChatMessageDTO - * @throws com.danielagapov.spawn.Exceptions.Base.BaseSaveException if saving fails - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if referenced user or activity doesn't exist - */ - ChatMessageDTO saveChatMessage(ChatMessageDTO chatMessage); - - /** - * Deletes a chat message by its unique identifier. - * - * @param id the unique identifier of the chat message to delete - * @return true if deletion was successful, false otherwise - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if chat message with given ID is not found - */ - boolean deleteChatMessageById(UUID id); - - /** - * Retrieves all chat messages associated with a specific activity, ordered by timestamp descending. - * - * @param activityId the unique identifier of the activity - * @return List of ChatMessageDTO objects for the specified activity - * @throws com.danielagapov.spawn.Exceptions.Base.BasesNotFoundException if database access fails - */ - List getChatMessagesByActivityId(UUID activityId); - - /** - * Retrieves the IDs of all chat messages associated with a specific activity. - * - * @param activityId the unique identifier of the activity - * @return List of UUID objects representing chat message IDs - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if activity doesn't exist - */ - List getChatMessageIdsByActivityId(UUID activityId); - - /** - * Batch method to retrieve chat message IDs for multiple activities at once. - * This prevents N+1 query problems when loading multiple activities. - * - * @param activityIds List of activity IDs to get chat messages for - * @return List of Object[] containing [activityId, messageId] pairs - */ - List getChatMessageIdsByActivityIds(List activityIds); - - /** - * Creates a like for a chat message from a specific user. - * - * @param chatMessageId the unique identifier of the chat message to like - * @param userId the unique identifier of the user creating the like - * @return ChatMessageLikesDTO representing the created like - * @throws com.danielagapov.spawn.Exceptions.EntityAlreadyExistsException if user already liked the message - * @throws com.danielagapov.spawn.Exceptions.Base.BaseSaveException if saving fails - */ - ChatMessageLikesDTO createChatMessageLike(UUID chatMessageId, UUID userId); - - /** - * Retrieves all users who have liked a specific chat message. - * - * @param chatMessageId the unique identifier of the chat message - * @return List of BaseUserDTO objects representing users who liked the message - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if chat message doesn't exist - */ - List getChatMessageLikes(UUID chatMessageId); - - /** - * Removes a like from a chat message for a specific user. - * - * @param chatMessageId the unique identifier of the chat message - * @param userId the unique identifier of the user removing the like - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if like doesn't exist - * @throws com.danielagapov.spawn.Exceptions.Base.BaseDeleteException if deletion fails - */ - void deleteChatMessageLike(UUID chatMessageId, UUID userId); - - /** - * Retrieves the user IDs of all users who have liked a specific chat message. - * - * @param chatMessageId the unique identifier of the chat message - * @return List of UUID objects representing user IDs who liked the message - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if chat message doesn't exist - */ - List getChatMessageLikeUserIds(UUID chatMessageId); - - /** - * Converts a ChatMessageDTO to a FullActivityChatMessageDTO with complete user information. - * - * @param chatMessage the ChatMessageDTO to convert - * @return FullActivityChatMessageDTO with populated user details and likes - */ - FullActivityChatMessageDTO getFullChatMessageByChatMessage(ChatMessageDTO chatMessage); - - /** - * Retrieves a full chat message by its unique identifier with complete user information. - * - * @param id the unique identifier of the chat message - * @return FullActivityChatMessageDTO with populated user details and likes - * @throws com.danielagapov.spawn.Exceptions.Base.BaseNotFoundException if chat message with given ID is not found - */ - FullActivityChatMessageDTO getFullChatMessageById(UUID id); - - /** - * Retrieves all full chat messages for a specific activity with complete user information. - * - * @param activityId the unique identifier of the activity - * @return List of FullActivityChatMessageDTO objects for the specified activity - * @throws com.danielagapov.spawn.Exceptions.Base.BasesNotFoundException if database access fails - */ - List getFullChatMessagesByActivityId(UUID activityId); - - /** - * Converts a list of ChatMessageDTO objects to FullActivityChatMessageDTO objects. - * - * @param chatMessages the list of ChatMessageDTO objects to convert - * @return List of FullActivityChatMessageDTO objects with populated user details - */ - List convertChatMessagesToFullFeedActivityChatMessages(List chatMessages); -} diff --git a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeEventListener.java b/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeEventListener.java similarity index 63% rename from src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeEventListener.java rename to src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeEventListener.java index 76bcfc195..37e85162c 100644 --- a/src/main/java/com/danielagapov/spawn/activity/internal/services/ActivityTypeEventListener.java +++ b/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeEventListener.java @@ -1,7 +1,8 @@ -package com.danielagapov.spawn.activity.internal.services; +package com.danielagapov.spawn.shared.config; import com.danielagapov.spawn.shared.events.UserActivityTypeEvents.*; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.domain.User; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; import org.springframework.context.ApplicationEventPublisher; @@ -10,54 +11,44 @@ import org.springframework.transaction.annotation.Transactional; /** - * Event listener for Activity module to handle user-related events. - * Specifically handles initializing default activity types for new users. - * This breaks the circular dependency between User and Activity modules. + * Event listener to handle user-related events for activity type initialization. + * When a user is created, initializes default activity types via activity-service. */ @Service public class ActivityTypeEventListener { - - private final IActivityTypeService activityTypeService; + + private final ActivityServiceClient activityServiceClient; private final IUserRepository userRepository; private final ApplicationEventPublisher eventPublisher; private final ILogger logger; - + public ActivityTypeEventListener( - IActivityTypeService activityTypeService, + ActivityServiceClient activityServiceClient, IUserRepository userRepository, ApplicationEventPublisher eventPublisher, ILogger logger) { - this.activityTypeService = activityTypeService; + this.activityServiceClient = activityServiceClient; this.userRepository = userRepository; this.eventPublisher = eventPublisher; this.logger = logger; } - - /** - * Handles the UserCreatedEvent to initialize default activity types for a new user. - * This replaces the direct call from UserService to ActivityTypeService. - */ + @EventListener @Transactional public void handleUserCreatedEvent(UserCreatedEvent event) { try { logger.info("Handling UserCreatedEvent for user: " + event.username() + " (ID: " + event.userId() + ")"); - - // Fetch the user entity + User user = userRepository.findById(event.userId()) .orElseThrow(() -> new IllegalStateException("User not found: " + event.userId())); - - // Initialize default activity types - activityTypeService.initializeDefaultActivityTypesForUser(user); - - // Publish success event + + activityServiceClient.initializeActivityTypesForUser(event.userId()); + eventPublisher.publishEvent(new DefaultActivityTypesInitializedEvent(event.userId(), 4)); - + logger.info("Successfully initialized default activity types for user: " + event.username()); } catch (Exception e) { logger.error("Failed to initialize default activity types for user " + event.userId() + ": " + e.getMessage()); - // Don't re-throw - we don't want to fail user creation if activity types fail to initialize } } } - diff --git a/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeInitializer.java b/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeInitializer.java index b48ab1fb8..319b6ce38 100644 --- a/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeInitializer.java +++ b/src/main/java/com/danielagapov/spawn/shared/config/ActivityTypeInitializer.java @@ -1,9 +1,9 @@ package com.danielagapov.spawn.shared.config; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.domain.User; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.JsonMappingException; import org.springframework.boot.CommandLineRunner; @@ -24,7 +24,7 @@ public class ActivityTypeInitializer { @Bean public CommandLineRunner initializeActivityTypes( IUserRepository userRepository, - IActivityTypeService activityTypeService, + ActivityServiceClient activityServiceClient, CacheManager cacheManager, ILogger logger) { @@ -45,7 +45,7 @@ public CommandLineRunner initializeActivityTypes( try { // Check if user has any activity types List existingActivityTypes = - activityTypeService.getActivityTypesByUserId(user.getId()); + activityServiceClient.getActivityTypesByUserId(user.getId()); if (existingActivityTypes.isEmpty()) { // User has no activity types, initialize them @@ -53,7 +53,7 @@ public CommandLineRunner initializeActivityTypes( // Initialize with retry logic for constraint violations try { - activityTypeService.initializeDefaultActivityTypesForUser(user); + activityServiceClient.initializeActivityTypesForUser(user.getId()); usersInitialized++; logger.info("Successfully initialized activity types for user: " + user.getUsername()); } catch (DataIntegrityViolationException e) { @@ -64,7 +64,7 @@ public CommandLineRunner initializeActivityTypes( // Re-check if user now has activity types (maybe partial success) List currentActivityTypes = - activityTypeService.getActivityTypesByUserId(user.getId()); + activityServiceClient.getActivityTypesByUserId(user.getId()); if (currentActivityTypes.isEmpty()) { logger.error("User " + user.getUsername() + " still has no activity types after constraint violation. " + @@ -115,11 +115,11 @@ public CommandLineRunner initializeActivityTypes( // Retry fetching activity types (will hit database now) List existingActivityTypes = - activityTypeService.getActivityTypesByUserId(user.getId()); + activityServiceClient.getActivityTypesByUserId(user.getId()); if (existingActivityTypes.isEmpty()) { // Initialize activity types for this user - activityTypeService.initializeDefaultActivityTypesForUser(user); + activityServiceClient.initializeActivityTypesForUser(user.getId()); usersInitialized++; cacheErrorsFixed++; logger.info("Successfully recovered from cache corruption and initialized activity types for user: " + diff --git a/src/main/java/com/danielagapov/spawn/shared/config/CacheController.java b/src/main/java/com/danielagapov/spawn/shared/config/CacheController.java index 3bcc327fd..449a8fa06 100644 --- a/src/main/java/com/danielagapov/spawn/shared/config/CacheController.java +++ b/src/main/java/com/danielagapov/spawn/shared/config/CacheController.java @@ -4,7 +4,7 @@ import com.danielagapov.spawn.shared.config.CacheValidationResponseDTO; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; import com.danielagapov.spawn.analytics.internal.services.ICacheService; -import com.danielagapov.spawn.activity.internal.services.ICalendarService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.shared.util.LoggingUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.ResponseEntity; @@ -22,13 +22,13 @@ public class CacheController { private final ICacheService cacheService; - private final ICalendarService calendarService; + private final ActivityServiceClient activityServiceClient; private final ILogger logger; @Autowired - public CacheController(ICacheService cacheService, ICalendarService calendarService, ILogger logger) { + public CacheController(ICacheService cacheService, ActivityServiceClient activityServiceClient, ILogger logger) { this.cacheService = cacheService; - this.calendarService = calendarService; + this.activityServiceClient = activityServiceClient; this.logger = logger; } @@ -79,7 +79,7 @@ public ResponseEntity> validateCache( public ResponseEntity clearCalendarCaches() { logger.info("Clearing all calendar caches"); try { - calendarService.clearAllCalendarCaches(); + activityServiceClient.clearAllCalendarCaches(); return ResponseEntity.ok("All calendar caches cleared successfully"); } catch (Exception e) { logger.error("Error clearing calendar caches: " + e.getMessage()); diff --git a/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java b/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java index c4b3ae6d7..9c75e31f2 100644 --- a/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java +++ b/src/main/java/com/danielagapov/spawn/shared/config/SecurityConfig.java @@ -30,6 +30,7 @@ @RequiredArgsConstructor @EnableMethodSecurity public class SecurityConfig { + private final TraceIdMdcFilter traceIdMdcFilter; private final JWTFilterConfig jwtFilterConfig; private final UserInfoService userInfoService; @@ -40,7 +41,9 @@ public class SecurityConfig { "/api/v1/auth/register/verification/check", "/api/v1/auth/sign-in", "/api/v1/auth/login", - "/api/v1/users/contacts/cross-reference" + "/api/v1/users/contacts/cross-reference", + "/actuator/health", + "/actuator/info" }; // Additional regex patterns for whitelisted URLs private final String[] whitelistedUrlPatterns = new String[] { @@ -153,6 +156,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti // 'Stateless' session management means Spring will not create and store any session state on the server // Each request is treated as 'new' and thus requires authentication (a JWT) to access secured endpoints .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .addFilterBefore(traceIdMdcFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilterConfig, UsernamePasswordAuthenticationFilter.class) ; return http.build(); diff --git a/src/main/java/com/danielagapov/spawn/shared/config/TraceIdMdcFilter.java b/src/main/java/com/danielagapov/spawn/shared/config/TraceIdMdcFilter.java new file mode 100644 index 000000000..3ee4c05c9 --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/config/TraceIdMdcFilter.java @@ -0,0 +1,41 @@ +package com.danielagapov.spawn.shared.config; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.lang.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +/** + * Puts the request trace ID into SLF4J MDC so it appears in log lines. + * When the API Gateway forwards requests it sets {@code X-Trace-Id}; this filter + * reads it (or generates one for direct calls) and clears MDC after the request. + */ +@Component +public class TraceIdMdcFilter extends OncePerRequestFilter { + + public static final String TRACE_ID_HEADER = "X-Trace-Id"; + public static final String MDC_KEY = "traceId"; + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) throws ServletException, IOException { + try { + String traceId = request.getHeader(TRACE_ID_HEADER); + if (traceId == null || traceId.isBlank()) { + traceId = UUID.randomUUID().toString(); + } + MDC.put(MDC_KEY, traceId); + filterChain.doFilter(request, response); + } finally { + MDC.remove(MDC_KEY); + } + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/ChatEvents.java b/src/main/java/com/danielagapov/spawn/shared/events/ChatEvents.java deleted file mode 100644 index 1a936c0b9..000000000 --- a/src/main/java/com/danielagapov/spawn/shared/events/ChatEvents.java +++ /dev/null @@ -1,93 +0,0 @@ -package com.danielagapov.spawn.shared.events; - -import java.util.List; -import java.util.UUID; - -/** - * Domain events for Chat module inter-module communication. - * Used to break circular dependencies between Activity and Chat modules. - */ -public final class ChatEvents { - - private ChatEvents() { - // Utility class - prevent instantiation - } - - /** - * Query event to request chat message IDs for a single activity. - * Published by Activity module, consumed by Chat module. - */ - public record GetChatMessageIdsQuery( - UUID activityId, - UUID requestId // Correlation ID for async response matching - ) {} - - /** - * Response event containing chat message IDs for an activity. - * Published by Chat module in response to GetChatMessageIdsQuery. - */ - public record ChatMessageIdsResponse( - UUID activityId, - UUID requestId, // Correlation ID to match with query - List messageIds - ) {} - - /** - * Query event to request chat message IDs for multiple activities (batch). - * Published by Activity module, consumed by Chat module. - */ - public record GetBatchChatMessageIdsQuery( - List activityIds, - UUID requestId // Correlation ID for async response matching - ) {} - - /** - * Single activity-messages pair for batch response. - */ - public record ActivityMessageIds( - UUID activityId, - List messageIds - ) {} - - /** - * Response event containing chat message IDs for multiple activities. - * Published by Chat module in response to GetBatchChatMessageIdsQuery. - */ - public record BatchChatMessageIdsResponse( - UUID requestId, // Correlation ID to match with query - List activityMessageIds - ) {} - - /** - * Query event to request full chat messages for an activity. - * Published by Activity module, consumed by Chat module. - */ - public record GetFullChatMessagesQuery( - UUID activityId, - UUID requestId // Correlation ID for async response matching - ) {} - - /** - * Simplified chat message data for cross-module communication. - * Avoids circular dependency by not using domain entities directly. - */ - public record ChatMessageData( - UUID id, - String content, - java.time.Instant timestamp, - UUID senderUserId, - UUID activityId, - List likedByUserIds - ) {} - - /** - * Response event containing full chat messages for an activity. - * Published by Chat module in response to GetFullChatMessagesQuery. - */ - public record FullChatMessagesResponse( - UUID activityId, - UUID requestId, // Correlation ID to match with query - List messages - ) {} -} - diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentEventSubscriber.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentEventSubscriber.java new file mode 100644 index 000000000..f806e0d5a --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentEventSubscriber.java @@ -0,0 +1,73 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; +import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; +import com.danielagapov.spawn.shared.events.NewCommentNotificationEvent; +import com.danielagapov.spawn.shared.util.ParticipationStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.UUID; + +/** + * Subscribes to the {@code events:new-comment} Redis channel (published by chat-service) + * and re-publishes as NewCommentNotificationEvent so the monolith's notification flow runs. + */ +@Component +public class NewCommentEventSubscriber implements MessageListener { + + private static final Logger log = LoggerFactory.getLogger(NewCommentEventSubscriber.class); + private final ApplicationEventPublisher springEventPublisher; + private final ActivityServiceClient activityServiceClient; + private final ObjectMapper objectMapper; + + public NewCommentEventSubscriber(ApplicationEventPublisher springEventPublisher, + ActivityServiceClient activityServiceClient) { + this.springEventPublisher = springEventPublisher; + this.activityServiceClient = activityServiceClient; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String json = new String(message.getBody()); + NewCommentRedisEvent event = objectMapper.readValue(json, NewCommentRedisEvent.class); + log.info("Received new-comment event for activityId={}, messageId={}", event.activityId(), event.messageId()); + + String activityTitle = activityServiceClient.getActivityTitle(event.activityId()); + UUID creatorId = activityServiceClient.getCreatorId(event.activityId()); + List participantIds = activityServiceClient.getParticipantUserIds( + event.activityId(), ParticipationStatus.participating); + + ChatMessageDTO messageDTO = new ChatMessageDTO( + event.messageId(), + event.content(), + null, + event.senderUserId(), + event.activityId(), + null + ); + + springEventPublisher.publishEvent(new NewCommentNotificationEvent( + event.senderUserId(), + event.senderUsername(), + event.activityId(), + activityTitle, + creatorId, + messageDTO, + participantIds + )); + } catch (Exception e) { + log.error("Failed to process new-comment event: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java new file mode 100644 index 000000000..ea422dc6b --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/NewCommentRedisEvent.java @@ -0,0 +1,16 @@ +package com.danielagapov.spawn.shared.events.redis; + +import java.io.Serializable; +import java.util.UUID; + +/** + * Event received from Redis when chat-service publishes a new comment. + * Matches the payload sent by chat-service. + */ +public record NewCommentRedisEvent( + UUID senderUserId, + String senderUsername, + UUID activityId, + UUID messageId, + String content +) implements Serializable {} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java new file mode 100644 index 000000000..dcdb939ed --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisEventChannels.java @@ -0,0 +1,35 @@ +package com.danielagapov.spawn.shared.events.redis; + +/** + * Redis Pub/Sub channel names for cross-service events. + *

+ * Centralised here so publishers and subscribers reference the same channel names. + * As more services are extracted, add new channels here. + */ +public final class RedisEventChannels { + + private RedisEventChannels() { + // utility class + } + + /** Published by auth-service when a new user completes registration. */ + public static final String USER_REGISTERED = "events:user-registered"; + + /** Published by auth-service when a user accepts Terms of Service. */ + public static final String USER_TOS_ACCEPTED = "events:user-tos-accepted"; + + /** Published by activity-service when a new activity is created. */ + public static final String ACTIVITY_CREATED = "events:activity-created"; + + /** Published by activity-service when users are invited to an activity. */ + public static final String ACTIVITY_INVITE = "events:activity-invite"; + + /** Published by activity-service when an activity is updated. */ + public static final String ACTIVITY_UPDATED = "events:activity-updated"; + + /** Published by activity-service when a user joins/leaves an activity. */ + public static final String ACTIVITY_PARTICIPATION_CHANGED = "events:activity-participation-changed"; + + /** Published by chat-service when a new comment is created. Monolith subscribes to send notifications. */ + public static final String NEW_COMMENT = "events:new-comment"; +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisSubscriberConfig.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisSubscriberConfig.java new file mode 100644 index 000000000..ae6b633a0 --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/RedisSubscriberConfig.java @@ -0,0 +1,40 @@ +package com.danielagapov.spawn.shared.events.redis; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.ChannelTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; +import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; + +/** + * Configures Redis Pub/Sub message listener container for the monolith. + *

+ * Subscribes to channels published by other microservices (e.g. auth-service) + * and routes messages to the appropriate handler methods. + */ +@Configuration +public class RedisSubscriberConfig { + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory, + UserRegisteredEventSubscriber userRegisteredSubscriber, + NewCommentEventSubscriber newCommentEventSubscriber) { + + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + + container.addMessageListener( + new MessageListenerAdapter(userRegisteredSubscriber, "onMessage"), + new ChannelTopic(RedisEventChannels.USER_REGISTERED) + ); + + container.addMessageListener( + new MessageListenerAdapter(newCommentEventSubscriber, "onMessage"), + new ChannelTopic(RedisEventChannels.NEW_COMMENT) + ); + + return container; + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java new file mode 100644 index 000000000..1475224f8 --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEvent.java @@ -0,0 +1,20 @@ +package com.danielagapov.spawn.shared.events.redis; + +import java.io.Serializable; +import java.time.Instant; +import java.util.UUID; + +/** + * Event received via Redis Pub/Sub when a new user registers. + *

+ * Published by the auth-service; consumed by the monolith to initialise + * default activity types, send welcome notifications, etc. + */ +public record UserRegisteredEvent( + UUID userId, + String email, + String username, + String provider, // "email", "google", or "apple" + Instant registeredAt +) implements Serializable { +} diff --git a/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEventSubscriber.java b/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEventSubscriber.java new file mode 100644 index 000000000..6f89cc189 --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/events/redis/UserRegisteredEventSubscriber.java @@ -0,0 +1,47 @@ +package com.danielagapov.spawn.shared.events.redis; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +/** + * Subscribes to the {@code events:user-registered} Redis channel and + * re-publishes the event as a Spring ApplicationEvent so existing + * in-process listeners (e.g. ActivityTypeEventListener) keep working + * without modification. + *

+ * This acts as a bridge: Redis Pub/Sub -> Spring ApplicationEventPublisher. + */ +@Component +public class UserRegisteredEventSubscriber implements MessageListener { + + private static final Logger log = LoggerFactory.getLogger(UserRegisteredEventSubscriber.class); + private final ApplicationEventPublisher springEventPublisher; + private final ObjectMapper objectMapper; + + public UserRegisteredEventSubscriber(ApplicationEventPublisher springEventPublisher) { + this.springEventPublisher = springEventPublisher; + this.objectMapper = new ObjectMapper(); + this.objectMapper.registerModule(new JavaTimeModule()); + } + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String json = new String(message.getBody()); + UserRegisteredEvent event = objectMapper.readValue(json, UserRegisteredEvent.class); + log.info("Received user-registered event for userId={}, email={}", event.userId(), event.email()); + + // Re-publish as a Spring event so existing @EventListeners are triggered. + // This keeps backward compatibility with the monolith's in-process event system. + springEventPublisher.publishEvent(event); + } catch (Exception e) { + log.error("Failed to process user-registered event: {}", e.getMessage(), e); + } + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/feign/ActivityServiceClient.java b/src/main/java/com/danielagapov/spawn/shared/feign/ActivityServiceClient.java new file mode 100644 index 000000000..4ad83496b --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/feign/ActivityServiceClient.java @@ -0,0 +1,95 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.activity.api.dto.*; +import com.danielagapov.spawn.shared.util.ParticipationStatus; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@FeignClient( + name = "activity-service-client", + url = "${services.activity-service.url:http://localhost:8082}" +) +public interface ActivityServiceClient { + + // ==================== Internal Query Endpoints ==================== + + @GetMapping("/api/v1/activities/internal/created-by/{userId}") + List getActivityIdsCreatedByUser(@PathVariable("userId") UUID userId); + + @GetMapping("/api/v1/activities/internal/by-user") + List getActivityIdsByUserAndStatus( + @RequestParam("userId") UUID userId, + @RequestParam("status") ParticipationStatus status); + + @GetMapping("/api/v1/activities/internal/{activityId}/participant-ids") + List getParticipantUserIds( + @PathVariable("activityId") UUID activityId, + @RequestParam("status") ParticipationStatus status); + + @GetMapping("/api/v1/activities/internal/{activityId}/creator-id") + UUID getCreatorId(@PathVariable("activityId") UUID activityId); + + @GetMapping("/api/v1/activities/internal/{activityId}/title") + String getActivityTitle(@PathVariable("activityId") UUID activityId); + + @GetMapping("/api/v1/activities/internal/past") + List getPastActivityIdsForUser( + @RequestParam("userId") UUID userId, + @RequestParam("status") ParticipationStatus status, + @RequestParam("limit") int limit); + + @PostMapping("/api/v1/activities/internal/other-users") + List getOtherUsersByActivities( + @RequestBody List activityIds, + @RequestParam("excludeUserId") UUID excludeUserId, + @RequestParam("status") ParticipationStatus status); + + @GetMapping("/api/v1/activities/internal/shared-count") + Integer getSharedActivitiesCount( + @RequestParam("userId1") UUID userId1, + @RequestParam("userId2") UUID userId2, + @RequestParam("status") ParticipationStatus status); + + @GetMapping("/api/v1/activities/internal/calendar") + List getCalendarActivities( + @RequestParam("userId") UUID userId, + @RequestParam(value = "month", required = false) Integer month, + @RequestParam(value = "year", required = false) Integer year); + + @GetMapping("/api/v1/activities/internal/timestamps/created") + Instant getLatestCreatedActivityTimestamp(@RequestParam("userId") UUID userId); + + @GetMapping("/api/v1/activities/internal/timestamps/invited") + Instant getLatestInvitedActivityTimestamp(@RequestParam("userId") UUID userId); + + @GetMapping("/api/v1/activities/internal/timestamps/updated") + Instant getLatestUpdatedActivityTimestamp(@RequestParam("userId") UUID userId); + + @GetMapping("/api/v1/activities/internal/activity-types/{userId}") + List getActivityTypesByUserId(@PathVariable("userId") UUID userId); + + @PostMapping("/api/v1/activities/internal/calendar/clear-all") + Void clearAllCalendarCaches(); + + @PostMapping("/api/v1/activities/internal/activity-types/initialize-for-user/{userId}") + Void initializeActivityTypesForUser(@PathVariable("userId") UUID userId); + + // ==================== Public API Endpoints (used by CacheService, ShareLink) ==================== + + @GetMapping("/api/v1/activities/feed-activities/{requestingUserId}") + List getFeedActivities(@PathVariable("requestingUserId") UUID requestingUserId); + + @GetMapping("/api/v1/activities/profile/{profileUserId}") + List getProfileActivities( + @PathVariable("profileUserId") UUID profileUserId, + @RequestParam("requestingUserId") UUID requestingUserId); + + @GetMapping("/api/v1/activities/{id}") + FullFeedActivityDTO getFullActivityById( + @PathVariable("id") UUID id, + @RequestParam(value = "requestingUserId", required = false) UUID requestingUserId); +} diff --git a/src/main/java/com/danielagapov/spawn/shared/feign/ChatServiceClient.java b/src/main/java/com/danielagapov/spawn/shared/feign/ChatServiceClient.java new file mode 100644 index 000000000..cfa4f719b --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/feign/ChatServiceClient.java @@ -0,0 +1,27 @@ +package com.danielagapov.spawn.shared.feign; + +import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; +import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; + +import java.util.List; +import java.util.UUID; + +/** + * Feign client for the chat-service. + * Used by the monolith when chat has been extracted to the chat microservice. + */ +@FeignClient( + name = "chat-service-client", + url = "${services.chat-service.url:http://localhost:8083}" +) +public interface ChatServiceClient { + + @GetMapping("/api/v1/chat-messages/{id}") + ChatMessageDTO getChatMessageById(@PathVariable("id") UUID id); + + @GetMapping("/api/v1/chat-messages/by-activity/{activityId}") + List getChatMessagesByActivityId(@PathVariable("activityId") UUID activityId); +} diff --git a/src/main/java/com/danielagapov/spawn/shared/util/ActivityExpirationUtil.java b/src/main/java/com/danielagapov/spawn/shared/util/ActivityExpirationUtil.java new file mode 100644 index 000000000..390b4a512 --- /dev/null +++ b/src/main/java/com/danielagapov/spawn/shared/util/ActivityExpirationUtil.java @@ -0,0 +1,50 @@ +package com.danielagapov.spawn.shared.util; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.time.ZoneOffset; + +/** + * Utility for activity expiration logic (share links, etc.). + * Extracted from ActivityExpirationService for use by monolith after activity extraction. + */ +public final class ActivityExpirationUtil { + + private ActivityExpirationUtil() {} + + /** + * Calculates when a share link for an activity should expire. + * Share links expire 1 day after the activity itself expires. + */ + public static OffsetDateTime calculateShareLinkExpiration( + OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt) { + OffsetDateTime activityExpiration = calculateActivityExpiration(startTime, endTime, createdAt, null); + return activityExpiration != null ? activityExpiration.plusDays(1) : null; + } + + private static OffsetDateTime calculateActivityExpiration( + OffsetDateTime startTime, OffsetDateTime endTime, Instant createdAt, String clientTimezone) { + if (endTime != null) { + return endTime.withOffsetSameInstant(ZoneOffset.UTC); + } + if (createdAt != null) { + if (clientTimezone != null && !clientTimezone.trim().isEmpty()) { + try { + ZoneId clientZone = ZoneId.of(clientTimezone); + LocalDate createdDate = createdAt.atZone(clientZone).toLocalDate(); + return createdDate.plusDays(1) + .atStartOfDay(clientZone) + .toOffsetDateTime() + .withOffsetSameInstant(ZoneOffset.UTC); + } catch (Exception ignored) { + // Fall through to UTC behavior + } + } + LocalDate createdDate = createdAt.atOffset(ZoneOffset.UTC).toLocalDate(); + return createdDate.plusDays(1).atStartOfDay(ZoneOffset.UTC).toOffsetDateTime(); + } + return null; + } +} diff --git a/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageLikesMapper.java b/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageLikesMapper.java deleted file mode 100644 index 7d68f8807..000000000 --- a/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageLikesMapper.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.danielagapov.spawn.shared.util; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageLikesDTO; -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikes; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikesId; -import com.danielagapov.spawn.user.internal.domain.User; - -public final class ChatMessageLikesMapper { - - // Convert entity to DTO - public static ChatMessageLikesDTO toDTO(ChatMessageLikes chatMessageLikes) { - return new ChatMessageLikesDTO( - chatMessageLikes.getChatMessage().getId(), - chatMessageLikes.getUser().getId() - ); - } - - // Convert DTO to entity - public static ChatMessageLikes toEntity(ChatMessageLikesDTO dto, ChatMessage chatMessage, User user) { - ChatMessageLikesId id = new ChatMessageLikesId(dto.getChatMessageId(), dto.getUserId()); - - return new ChatMessageLikes( - id, - chatMessage, - user - ); - } -} diff --git a/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageMapper.java b/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageMapper.java deleted file mode 100644 index 2d495ac0e..000000000 --- a/src/main/java/com/danielagapov/spawn/shared/util/ChatMessageMapper.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.danielagapov.spawn.shared.util; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -import com.danielagapov.spawn.user.internal.domain.User; - -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.stream.Collectors; - -public final class ChatMessageMapper { - - public static ChatMessageDTO toDTO(ChatMessage entity, List likedByUserIds) { - return new ChatMessageDTO( - entity.getId(), - entity.getContent(), - entity.getTimestamp(), - entity.getUserSender().getId(), - entity.getActivity().getId(), - likedByUserIds - ); - } - - public static ChatMessage toEntity(ChatMessageDTO dto, User userSender, Activity activity) { - return new ChatMessage( - dto.getId(), // null for new entities, existing ID for updates - dto.getContent(), - dto.getTimestamp(), - userSender, - activity - ); - } - - public static List toDTOList( - List chatMessages, - Map> likedByUserIdsMap - ) { - return chatMessages.stream() - .map(chatMessage -> toDTO( - chatMessage, - likedByUserIdsMap.getOrDefault(chatMessage, List.of()) // Default to an empty list if likedByUserIds is missing - )) - .collect(Collectors.toList()); - } - - public static List toEntityList(List chatMessageDTOs, List users, List activities) { - return chatMessageDTOs.stream() - .map(dto -> { - User userSender = users.stream() - .filter(user -> user.getId().equals(dto.getSenderUserId())) - .findFirst() - .orElse(null); - Activity activity = activities.stream() - .filter(ev -> ev.getId().equals(dto.getActivityId())) - .findFirst() - .orElse(null); - return toEntity(dto, userSender, activity); - }) - .collect(Collectors.toList()); - } -} \ No newline at end of file diff --git a/src/main/java/com/danielagapov/spawn/user/api/CalendarController.java b/src/main/java/com/danielagapov/spawn/user/api/CalendarController.java index 8dd58ab55..a7635a1d0 100644 --- a/src/main/java/com/danielagapov/spawn/user/api/CalendarController.java +++ b/src/main/java/com/danielagapov/spawn/user/api/CalendarController.java @@ -1,9 +1,8 @@ package com.danielagapov.spawn.user.api; import com.danielagapov.spawn.activity.api.dto.CalendarActivityDTO; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.activity.internal.services.ICalendarService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.shared.util.LoggingUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -17,12 +16,12 @@ @RequestMapping("/api/v1/users/{userId}/calendar") public class CalendarController { - private final ICalendarService calendarService; + private final ActivityServiceClient activityServiceClient; private final ILogger logger; @Autowired - public CalendarController(ICalendarService calendarService, ILogger logger) { - this.calendarService = calendarService; + public CalendarController(ActivityServiceClient activityServiceClient, ILogger logger) { + this.activityServiceClient = activityServiceClient; this.logger = logger; } @@ -32,41 +31,15 @@ public ResponseEntity> getCalendarActivities( @RequestParam(required = false) Integer month, @RequestParam(required = false) Integer year) { - logger.info("Calendar API called for user: " + userId + - (month != null ? ", month: " + month : "") + - (year != null ? ", year: " + year : "")); - if (userId == null) { - logger.error("Invalid parameter: userId is null"); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } try { - List activities = calendarService.getCalendarActivitiesWithFilters(userId, month, year); - - logger.info("Successfully retrieved " + activities.size() + " calendar activities for user: " + userId); - - // Log sample activities for debugging - if (!activities.isEmpty()) { - logger.info("Sample calendar activities:"); - for (int i = 0; i < Math.min(activities.size(), 3); i++) { - CalendarActivityDTO activity = activities.get(i); - logger.info(" " + (i + 1) + ". " + activity.getTitle() + " on " + activity.getDate() + - " (ID: " + activity.getId() + ")"); - } - } else { - logger.warn("No calendar activities found for user: " + userId + - (month != null ? ", month: " + month : "") + - (year != null ? ", year: " + year : "")); - } - + List activities = activityServiceClient.getCalendarActivities(userId, month, year); return ResponseEntity.ok(activities); - } catch (BaseNotFoundException e) { - logger.error("User not found for calendar activities: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.NOT_FOUND); } catch (Exception e) { - logger.error("Error getting calendar activities for user: " + LoggingUtils.formatUserIdInfo(userId) + - ": " + e.getMessage() + ", Stack trace: " + java.util.Arrays.toString(e.getStackTrace())); + logger.error("Error getting calendar activities for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); } } @@ -75,16 +48,12 @@ public ResponseEntity> getCalendarActivities( public ResponseEntity> getAllCalendarActivities( @PathVariable UUID userId) { if (userId == null) { - logger.error("Invalid parameter: userId is null"); return new ResponseEntity<>(HttpStatus.BAD_REQUEST); } try { - List activities = calendarService.getCalendarActivitiesWithFilters(userId, null, null); + List activities = activityServiceClient.getCalendarActivities(userId, null, null); return ResponseEntity.ok(activities); - } catch (BaseNotFoundException e) { - logger.error("User not found for all calendar activities: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); - return new ResponseEntity<>(HttpStatus.NOT_FOUND); } catch (Exception e) { logger.error("Error getting all calendar activities for user: " + LoggingUtils.formatUserIdInfo(userId) + ": " + e.getMessage()); return new ResponseEntity<>(HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/src/main/java/com/danielagapov/spawn/user/internal/services/RecentlySpawnedService.java b/src/main/java/com/danielagapov/spawn/user/internal/services/RecentlySpawnedService.java index 5851bd13a..4bb47fd77 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/services/RecentlySpawnedService.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/services/RecentlySpawnedService.java @@ -1,33 +1,23 @@ package com.danielagapov.spawn.user.internal.services; -import com.danielagapov.spawn.activity.api.IActivityService; import com.danielagapov.spawn.activity.api.dto.UserIdActivityTimeDTO; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.shared.util.LoggingUtils; import com.danielagapov.spawn.shared.util.ParticipationStatus; import com.danielagapov.spawn.user.api.dto.RecentlySpawnedUserDTO; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.domain.Limit; import org.springframework.stereotype.Service; -import java.time.OffsetDateTime; import java.util.List; import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; -/** - * Service implementation for retrieving users that a user has recently done activities with. - * - * This service breaks the circular dependency between UserService and ActivityService by: - * - Depending on IActivityService (for activity queries) - * - Depending on IUserSearchQueryService (for user queries, not IUserService) - * - Not being depended upon by either UserService or ActivityService - */ @Service public class RecentlySpawnedService implements IRecentlySpawnedService { - private final IActivityService activityService; + private final ActivityServiceClient activityServiceClient; private final IUserSearchQueryService userSearchQueryService; private final IUserService userService; private final ILogger logger; @@ -37,11 +27,11 @@ public class RecentlySpawnedService implements IRecentlySpawnedService { @Autowired public RecentlySpawnedService( - IActivityService activityService, + ActivityServiceClient activityServiceClient, IUserSearchQueryService userSearchQueryService, IUserService userService, ILogger logger) { - this.activityService = activityService; + this.activityServiceClient = activityServiceClient; this.userSearchQueryService = userSearchQueryService; this.userService = userService; this.logger = logger; @@ -50,28 +40,20 @@ public RecentlySpawnedService( @Override public List getRecentlySpawnedWithUsers(UUID requestingUserId) { try { - // Use UTC for consistent timezone comparison across server and client timezones - OffsetDateTime now = OffsetDateTime.now(java.time.ZoneOffset.UTC); - - // Get past activities the user participated in - List pastActivityIds = activityService.getPastActivityIdsForUser( + List pastActivityIds = activityServiceClient.getPastActivityIdsForUser( requestingUserId, ParticipationStatus.participating, - now, - Limit.of(ACTIVITY_LIMIT) + ACTIVITY_LIMIT ); - // Get other users from those activities - List pastActivityParticipantIds = activityService.getOtherUserIdsByActivityIds( + List pastActivityParticipantIds = activityServiceClient.getOtherUsersByActivities( pastActivityIds, requestingUserId, ParticipationStatus.participating ); - // Get users to exclude (e.g., already friends, blocked) Set excludedIds = userSearchQueryService.getExcludedUserIds(requestingUserId); - // Convert to DTOs, filtering excluded users return pastActivityParticipantIds.stream() .filter(e -> !excludedIds.contains(e.getUserId())) .map(e -> new RecentlySpawnedUserDTO( diff --git a/src/main/java/com/danielagapov/spawn/user/internal/services/UserSearchService.java b/src/main/java/com/danielagapov/spawn/user/internal/services/UserSearchService.java index 1e9b4c282..ffc5a7243 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/services/UserSearchService.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/services/UserSearchService.java @@ -13,7 +13,7 @@ import com.danielagapov.spawn.shared.util.FriendUserMapper; import com.danielagapov.spawn.shared.util.UserMapper; import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.api.IActivityService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; import com.danielagapov.spawn.analytics.internal.services.SearchAnalyticsService; import com.danielagapov.spawn.social.internal.services.IBlockedUserService; @@ -49,7 +49,7 @@ public class UserSearchService implements IUserSearchService { private final IUserFriendshipQueryService friendshipQueryService; private final IUserRepository userRepository; private final IBlockedUserService blockedUserService; - private final IActivityService activityService; + private final ActivityServiceClient activityServiceClient; private final IFuzzySearchService fuzzySearchService; private final SearchAnalyticsService searchAnalyticsService; private final ILogger logger; @@ -62,7 +62,7 @@ public UserSearchService(IFriendRequestService friendRequestService, IUserFriendshipQueryService friendshipQueryService, IUserRepository userRepository, IBlockedUserService blockedUserService, - IActivityService activityService, + ActivityServiceClient activityServiceClient, IFuzzySearchService fuzzySearchService, SearchAnalyticsService searchAnalyticsService, ILogger logger) { @@ -71,7 +71,7 @@ public UserSearchService(IFriendRequestService friendRequestService, this.friendshipQueryService = friendshipQueryService; this.userRepository = userRepository; this.blockedUserService = blockedUserService; - this.activityService = activityService; + this.activityServiceClient = activityServiceClient; this.fuzzySearchService = fuzzySearchService; this.searchAnalyticsService = searchAnalyticsService; this.logger = logger; @@ -390,8 +390,7 @@ private Map getMutualFriendCounts(List requestingUserFriend */ private int getSharedActivitiesCount(UUID requestingUserId, UUID potentialFriendId) { try { - // Use the IActivityService to get shared activities count - return activityService.getSharedActivitiesCount(requestingUserId, potentialFriendId, ParticipationStatus.participating); + return activityServiceClient.getSharedActivitiesCount(requestingUserId, potentialFriendId, ParticipationStatus.participating); } catch (Exception e) { logger.error("Error calculating shared activities between users " + requestingUserId + " and " + potentialFriendId + ": " + e.getMessage()); return 0; diff --git a/src/main/java/com/danielagapov/spawn/user/internal/services/UserStatsService.java b/src/main/java/com/danielagapov/spawn/user/internal/services/UserStatsService.java index 0eda8bbb0..3e3fb66ef 100644 --- a/src/main/java/com/danielagapov/spawn/user/internal/services/UserStatsService.java +++ b/src/main/java/com/danielagapov/spawn/user/internal/services/UserStatsService.java @@ -4,7 +4,7 @@ import com.danielagapov.spawn.shared.util.EntityType; import com.danielagapov.spawn.shared.util.ParticipationStatus; import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.activity.api.IActivityService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.cache.annotation.Cacheable; @@ -18,14 +18,14 @@ @Service public class UserStatsService implements IUserStatsService { - private final IActivityService activityService; + private final ActivityServiceClient activityServiceClient; private final IUserRepository userRepository; @Autowired public UserStatsService( - IActivityService activityService, + ActivityServiceClient activityServiceClient, IUserRepository userRepository) { - this.activityService = activityService; + this.activityServiceClient = activityServiceClient; this.userRepository = userRepository; } @@ -36,25 +36,20 @@ public UserStatsDTO getUserStats(UUID userId) { throw new BaseNotFoundException(EntityType.User, userId); } - // Get activities created by user - List createdActivityIds = activityService.getActivityIdsCreatedByUser(userId); + List createdActivityIds = activityServiceClient.getActivityIdsCreatedByUser(userId); int spawnsMade = createdActivityIds.size(); - // Get activities participated in - List participatedActivityIds = activityService.getActivityIdsByUserIdAndStatus(userId, ParticipationStatus.participating); + List participatedActivityIds = activityServiceClient.getActivityIdsByUserAndStatus(userId, ParticipationStatus.participating); - // Filter out activities created by the user (spawns joined = participated but not created) Set createdSet = new HashSet<>(createdActivityIds); int spawnsJoined = (int) participatedActivityIds.stream() .filter(activityId -> !createdSet.contains(activityId)) .count(); - // Get all unique users that this user has participated in Activities with Set peopleMet = new HashSet<>(); - // Add people from activities created by the user for (UUID activityId : createdActivityIds) { - List participantIds = activityService.getParticipantUserIdsByActivityIdAndStatus(activityId, ParticipationStatus.participating); + List participantIds = activityServiceClient.getParticipantUserIds(activityId, ParticipationStatus.participating); for (UUID participantId : participantIds) { if (!participantId.equals(userId)) { peopleMet.add(participantId); @@ -62,16 +57,13 @@ public UserStatsDTO getUserStats(UUID userId) { } } - // Add people from activities the user participated in for (UUID activityId : participatedActivityIds) { - // Add the creator if it's not the user - UUID creatorId = activityService.getActivityCreatorId(activityId); + UUID creatorId = activityServiceClient.getCreatorId(activityId); if (creatorId != null && !creatorId.equals(userId)) { peopleMet.add(creatorId); } - // Add other participants - List participantIds = activityService.getParticipantUserIdsByActivityIdAndStatus(activityId, ParticipationStatus.participating); + List participantIds = activityServiceClient.getParticipantUserIds(activityId, ParticipationStatus.participating); for (UUID participantId : participantIds) { if (!participantId.equals(userId)) { peopleMet.add(participantId); diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 91cb8a680..411ba1474 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -109,4 +109,16 @@ server.tomcat.max-keep-alive-requests=100 spring.datasource.hikari.data-source-properties.cachePrepStmts=true spring.datasource.hikari.data-source-properties.prepStmtCacheSize=250 spring.datasource.hikari.data-source-properties.prepStmtCacheSqlLimit=2048 -spring.datasource.hikari.data-source-properties.useServerPrepStmts=true \ No newline at end of file +spring.datasource.hikari.data-source-properties.useServerPrepStmts=true + +# ============================================================================ +# Microservice URLs (Feign clients) +# ============================================================================ +services.activity-service.url=${ACTIVITY_SERVICE_URL:http://localhost:8082} +services.chat-service.url=${CHAT_SERVICE_URL:http://localhost:8083} + +# ============================================================================ +# Actuator (Health Checks & Monitoring) +# ============================================================================ +management.endpoints.web.exposure.include=health,info +management.endpoint.health.show-details=always \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index c02f5c2e4..9ec71bcbc 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,7 +1,7 @@ - %d{HH:mm:ss} %highlight(%-5level) [%logger{36}:%line] %msg%n + %d{yyyy-MM-dd'T'HH:mm:ss.SSSZ} %-5level [%logger{36}:%line] traceId=%X{traceId:-} %msg%n diff --git a/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityControllerTests.java b/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityControllerTests.java deleted file mode 100644 index 30f27ec34..000000000 --- a/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityControllerTests.java +++ /dev/null @@ -1,644 +0,0 @@ -package com.danielagapov.spawn.ControllerTests; - -import com.danielagapov.spawn.activity.api.ActivityController; -import com.danielagapov.spawn.activity.api.dto.*; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.shared.util.ParticipationStatus; -import com.danielagapov.spawn.shared.exceptions.ActivityFullException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Base.BasesNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.activity.api.IActivityService; -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.time.Instant; -import java.time.OffsetDateTime; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Comprehensive unit tests for ActivityController - * Tests all API endpoints for activity management, feed, participation, etc. - */ -@ExtendWith(MockitoExtension.class) -class ActivityControllerTests { - - @Mock - private IActivityService activityService; - - @Mock - private ILogger logger; - - @InjectMocks - private ActivityController activityController; - - private MockMvc mockMvc; - private ObjectMapper objectMapper; - private UUID activityId; - private UUID userId; - private UUID creatorId; - private LocationDTO locationDTO; - private ActivityDTO activityDTO; - private FullFeedActivityDTO fullFeedActivityDTO; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - - mockMvc = MockMvcBuilders.standaloneSetup(activityController) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .build(); - - activityId = UUID.randomUUID(); - userId = UUID.randomUUID(); - creatorId = UUID.randomUUID(); - - locationDTO = new LocationDTO(UUID.randomUUID(), "Test Location", 40.7128, -74.0060); - - activityDTO = new ActivityDTO( - activityId, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - locationDTO, - null, - "Test note", - "🎉", - 5, - creatorId, - List.of(), - List.of(), - List.of(), - Instant.now(), - false, - "America/New_York" - ); - - // BaseUserDTO(UUID id, String username, String profilePictureUrlString, String name, String email, String bio) - BaseUserDTO creator = new BaseUserDTO(creatorId, "testuser", "pic.jpg", "Test User", "test@example.com", "bio"); - - // FullFeedActivityDTO constructor: - // (UUID id, String title, OffsetDateTime startTime, OffsetDateTime endTime, LocationDTO location, - // UUID activityTypeId, String note, String icon, Integer participantLimit, BaseUserDTO creatorUser, - // List participantUsers, List invitedUsers, List chatMessages, - // ParticipationStatus participationStatus, boolean isSelfOwned, Instant createdAt, boolean isExpired, String clientTimezone) - fullFeedActivityDTO = new FullFeedActivityDTO( - activityId, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - locationDTO, - null, // activityTypeId - "Test note", - "🎉", - 5, - creator, - List.of(), // participantUsers - List.of(), // invitedUsers - List.of(), // chatMessages - ParticipationStatus.participating, // participationStatus - false, // isSelfOwned - Instant.now(), - false, // isExpired - "America/New_York" - ); - } - - // MARK: - GET Profile Activities Tests - - @Test - void getProfileActivities_ShouldReturnActivities_WhenValidRequest() throws Exception { - UUID profileUserId = UUID.randomUUID(); - UUID requestingUserId = UUID.randomUUID(); - - // Create a proper ProfileActivityDTO using the full constructor - BaseUserDTO creator = new BaseUserDTO(creatorId, "testuser", "pic.jpg", "Test User", "test@example.com", "bio"); - ProfileActivityDTO profileActivity = new ProfileActivityDTO( - activityId, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - locationDTO, - "Test note", - "🎉", - 5, - creator, - List.of(), // participantUsers - List.of(), // invitedUsers - List.of(), // chatMessageIds - Instant.now(), - false, // isExpired - "America/New_York", - false // isPastActivity - ); - List activities = List.of(profileActivity); - - when(activityService.getProfileActivities(profileUserId, requestingUserId)).thenReturn(activities); - - mockMvc.perform(get("/api/v1/activities/profile/{profileUserId}", profileUserId) - .param("requestingUserId", requestingUserId.toString())) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].id").value(activityId.toString())); - - verify(activityService, times(1)).getProfileActivities(profileUserId, requestingUserId); - } - - @Test - void getProfileActivities_ShouldReturnBadRequest_WhenNullParameters() throws Exception { - UUID profileUserId = UUID.randomUUID(); - - mockMvc.perform(get("/api/v1/activities/profile/{profileUserId}", profileUserId)) - .andExpect(status().isBadRequest()); - - verify(activityService, never()).getProfileActivities(any(), any()); - } - - @Test - void getProfileActivities_ShouldReturnNotFound_WhenUserNotFound() throws Exception { - UUID profileUserId = UUID.randomUUID(); - UUID requestingUserId = UUID.randomUUID(); - - when(activityService.getProfileActivities(profileUserId, requestingUserId)) - .thenThrow(new BaseNotFoundException(EntityType.User, profileUserId)); - - mockMvc.perform(get("/api/v1/activities/profile/{profileUserId}", profileUserId) - .param("requestingUserId", requestingUserId.toString())) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("User not found")); - } - - // MARK: - POST Create Activity Tests - - @Test - void createActivity_ShouldReturnCreated_WhenValidActivity() throws Exception { - when(activityService.createActivityWithSuggestions(any(ActivityDTO.class))) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(post("/api/v1/activities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.activity.id").value(activityId.toString())) - .andExpect(jsonPath("$.activity.title").value("Test Activity")) - .andExpect(jsonPath("$.friendSuggestion").doesNotExist()); - - verify(activityService, times(1)).createActivityWithSuggestions(any(ActivityDTO.class)); - } - - @Test - void createActivity_ShouldReturnBadRequest_WhenInvalidData() throws Exception { - when(activityService.createActivityWithSuggestions(any(ActivityDTO.class))) - .thenThrow(new IllegalArgumentException("Invalid activity data")); - - mockMvc.perform(post("/api/v1/activities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isBadRequest()); - - verify(logger, times(1)).error(contains("Invalid request for activity creation")); - } - - @Test - void createActivity_ShouldReturnNotFound_WhenEntityNotFound() throws Exception { - when(activityService.createActivityWithSuggestions(any(ActivityDTO.class))) - .thenThrow(new BaseNotFoundException(EntityType.User, creatorId)); - - mockMvc.perform(post("/api/v1/activities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Entity not found during activity creation")); - } - - // MARK: - PUT Replace Activity Tests - - @Test - void replaceActivity_ShouldReturnOk_WhenActivityReplaced() throws Exception { - when(activityService.replaceActivity(any(ActivityDTO.class), eq(activityId))) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(put("/api/v1/activities/{id}", activityId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(activityId.toString())); - - verify(activityService, times(1)).replaceActivity(any(ActivityDTO.class), eq(activityId)); - } - - @Test - void replaceActivity_ShouldReturnNotFound_WhenInvalidId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 404 or 405 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(put("/api/v1/activities/invalid-uuid") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isBadRequest()); - } - - @Test - void replaceActivity_ShouldReturnNotFound_WhenActivityNotFound() throws Exception { - when(activityService.replaceActivity(any(ActivityDTO.class), eq(activityId))) - .thenThrow(new BaseNotFoundException(EntityType.Activity, activityId)); - - mockMvc.perform(put("/api/v1/activities/{id}", activityId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(activityDTO))) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Activity not found for replacement")); - } - - // MARK: - PATCH Partial Update Activity Tests - - @Test - void partialUpdateActivity_ShouldReturnOk_WhenValidUpdate() throws Exception { - ActivityPartialUpdateDTO updates = new ActivityPartialUpdateDTO(); - updates.setTitle("Updated Title"); - - when(activityService.partialUpdateActivity(any(ActivityPartialUpdateDTO.class), eq(activityId))) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(patch("/api/v1/activities/{id}/partial", activityId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updates))) - .andExpect(status().isOk()); - - verify(activityService, times(1)).partialUpdateActivity(any(ActivityPartialUpdateDTO.class), eq(activityId)); - } - - @Test - void partialUpdateActivity_ShouldReturnBadRequest_WhenInvalidData() throws Exception { - ActivityPartialUpdateDTO updates = new ActivityPartialUpdateDTO(); - updates.setStartTime(OffsetDateTime.now().minusDays(1).toString()); // Past date - - when(activityService.partialUpdateActivity(any(ActivityPartialUpdateDTO.class), eq(activityId))) - .thenThrow(new IllegalArgumentException("Activity start time cannot be in the past")); - - mockMvc.perform(patch("/api/v1/activities/{id}/partial", activityId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updates))) - .andExpect(status().isBadRequest()); - - verify(logger, times(1)).error(contains("Invalid update data")); - } - - // MARK: - DELETE Activity Tests - - @Test - void deleteActivity_ShouldReturnNoContent_WhenSuccessful() throws Exception { - when(activityService.deleteActivityById(activityId)).thenReturn(true); - - mockMvc.perform(delete("/api/v1/activities/{id}", activityId)) - .andExpect(status().isNoContent()); - - verify(activityService, times(1)).deleteActivityById(activityId); - } - - @Test - void deleteActivity_ShouldReturnNotFound_WhenActivityNotFound() throws Exception { - when(activityService.deleteActivityById(activityId)) - .thenThrow(new BaseNotFoundException(EntityType.Activity, activityId)); - - mockMvc.perform(delete("/api/v1/activities/{id}", activityId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Activity not found for deletion")); - } - - @Test - void deleteActivity_ShouldReturnInternalServerError_WhenDeletionFails() throws Exception { - when(activityService.deleteActivityById(activityId)).thenReturn(false); - - mockMvc.perform(delete("/api/v1/activities/{id}", activityId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Failed to delete activity")); - } - - // MARK: - PUT Toggle Participation Tests - - @Test - void toggleParticipation_ShouldReturnOk_WhenSuccessful() throws Exception { - when(activityService.toggleParticipation(activityId, userId)) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(put("/api/v1/activities/{activityId}/toggle-status/{userId}", activityId, userId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(activityId.toString())); - - verify(activityService, times(1)).toggleParticipation(activityId, userId); - } - - @Test - void toggleParticipation_ShouldReturnBadRequest_WhenInvalidUuid() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 404 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(put("/api/v1/activities/{activityId}/toggle-status/invalid-uuid", activityId)) - .andExpect(status().isBadRequest()); - - verify(activityService, never()).toggleParticipation(any(), any()); - } - - @Test - void toggleParticipation_ShouldReturnBadRequest_WhenActivityFull() throws Exception { - // ActivityFullException(UUID activityId, Integer participantLimit) - when(activityService.toggleParticipation(activityId, userId)) - .thenThrow(new ActivityFullException(activityId, 5)); - - mockMvc.perform(put("/api/v1/activities/{activityId}/toggle-status/{userId}", activityId, userId)) - .andExpect(status().isBadRequest()); - - verify(logger, times(1)).error(contains("Activity is full")); - } - - @Test - void toggleParticipation_ShouldReturnNotFound_WhenUserNotInvited() throws Exception { - when(activityService.toggleParticipation(activityId, userId)) - .thenThrow(new BaseNotFoundException(EntityType.ActivityUser, activityId)); - - mockMvc.perform(put("/api/v1/activities/{activityId}/toggle-status/{userId}", activityId, userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("User not invited to activity")); - } - - // MARK: - GET Feed Activities Tests - - @Test - void getFeedActivities_ShouldReturnActivities_WhenValidRequest() throws Exception { - List feedActivities = List.of(fullFeedActivityDTO); - when(activityService.getFeedActivities(userId)).thenReturn(feedActivities); - - mockMvc.perform(get("/api/v1/activities/feed-activities/{requestingUserId}", userId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].id").value(activityId.toString())); - - verify(activityService, times(1)).getFeedActivities(userId); - } - - @Test - void getFeedActivities_ShouldReturnEmptyList_WhenNoActivitiesFound() throws Exception { - when(activityService.getFeedActivities(userId)) - .thenThrow(new BasesNotFoundException(EntityType.Activity)); - - mockMvc.perform(get("/api/v1/activities/feed-activities/{requestingUserId}", userId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(0)); - - verify(activityService, times(1)).getFeedActivities(userId); - } - - @Test - void getFeedActivities_ShouldReturnNotFound_WhenUserNotFound() throws Exception { - when(activityService.getFeedActivities(userId)) - .thenThrow(new BaseNotFoundException(EntityType.User, userId)); - - mockMvc.perform(get("/api/v1/activities/feed-activities/{requestingUserId}", userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("User not found for feed activities")); - } - - // MARK: - GET Full Activity By ID Tests - - @Test - void getFullActivityById_ShouldReturnActivity_WhenValidRequest() throws Exception { - when(activityService.getFullActivityById(activityId, userId)) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(get("/api/v1/activities/{id}", activityId) - .param("requestingUserId", userId.toString())) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(activityId.toString())) - .andExpect(jsonPath("$.title").value("Test Activity")); - - verify(activityService, times(1)).getFullActivityById(activityId, userId); - } - - @Test - void getFullActivityById_ShouldReturnActivityInvite_WhenExternalInvite() throws Exception { - // ActivityInviteDTO constructor: - // (UUID id, String title, OffsetDateTime startTime, OffsetDateTime endTime, UUID locationId, - // UUID activityTypeId, String note, String icon, Integer participantLimit, UUID creatorUserId, - // List participantUserIds, List invitedUserIds, Instant createdAt, boolean isExpired, String clientTimezone) - ActivityInviteDTO inviteDTO = new ActivityInviteDTO( - activityId, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - locationDTO.getId(), - null, // activityTypeId - "Test note", - "🎉", - 5, - creatorId, - List.of(), // participantUserIds - List.of(), // invitedUserIds - Instant.now(), - false, // isExpired - "America/New_York" - ); - - when(activityService.getActivityInviteById(activityId)).thenReturn(inviteDTO); - - mockMvc.perform(get("/api/v1/activities/{id}", activityId) - .param("isActivityExternalInvite", "true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(activityId.toString())); - - verify(activityService, times(1)).getActivityInviteById(activityId); - verify(activityService, never()).getFullActivityById(any(), any()); - } - - @Test - void getFullActivityById_ShouldAutoJoin_WhenAutoJoinTrue() throws Exception { - when(activityService.autoJoinUserToActivity(activityId, userId)) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(get("/api/v1/activities/{id}", activityId) - .param("requestingUserId", userId.toString()) - .param("autoJoin", "true")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.id").value(activityId.toString())); - - verify(activityService, times(1)).autoJoinUserToActivity(activityId, userId); - verify(activityService, never()).getFullActivityById(any(), any()); - } - - @Test - void getFullActivityById_ShouldReturnEmptyList_WhenActivityNotFound() throws Exception { - when(activityService.getFullActivityById(activityId, userId)) - .thenThrow(new BaseNotFoundException(EntityType.Activity, activityId)); - - mockMvc.perform(get("/api/v1/activities/{id}", activityId) - .param("requestingUserId", userId.toString())) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(0)); - - verify(activityService, times(1)).getFullActivityById(activityId, userId); - } - - // MARK: - GET Chat Messages Tests - - @Test - void getChatMessagesForActivity_ShouldReturnMessages_WhenValidRequest() throws Exception { - List messages = List.of(); - when(activityService.getChatMessagesByActivityId(activityId)).thenReturn(messages); - - mockMvc.perform(get("/api/v1/activities/{activityId}/chats", activityId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)); - - verify(activityService, times(1)).getChatMessagesByActivityId(activityId); - } - - @Test - void getChatMessagesForActivity_ShouldReturnInternalServerError_WhenServiceFails() throws Exception { - when(activityService.getChatMessagesByActivityId(activityId)) - .thenThrow(new RuntimeException("Database error")); - - mockMvc.perform(get("/api/v1/activities/{activityId}/chats", activityId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error getting chat messages")); - } - - // MARK: - Direct Controller Method Tests - - @Test - void createActivity_DirectCall_ShouldReturnCreated_WhenSuccessful() { - when(activityService.createActivityWithSuggestions(any(ActivityDTO.class))) - .thenReturn(fullFeedActivityDTO); - - ResponseEntity response = activityController.createActivity(activityDTO); - - assertEquals(HttpStatus.CREATED, response.getStatusCode()); - assertNotNull(response.getBody()); - assertNotNull(response.getBody().getActivity()); - assertEquals(activityId, response.getBody().getActivity().getId()); - verify(activityService, times(1)).createActivityWithSuggestions(any(ActivityDTO.class)); - } - - @Test - void toggleParticipation_DirectCall_ShouldReturnOk_WhenSuccessful() { - when(activityService.toggleParticipation(activityId, userId)) - .thenReturn(fullFeedActivityDTO); - - ResponseEntity response = activityController.toggleParticipation(activityId, userId); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - verify(activityService, times(1)).toggleParticipation(activityId, userId); - } - - @Test - void getFeedActivities_DirectCall_ShouldReturnOk_WhenSuccessful() { - List feedActivities = List.of(fullFeedActivityDTO); - when(activityService.getFeedActivities(userId)).thenReturn(feedActivities); - - ResponseEntity response = activityController.getFeedActivities(userId); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - verify(activityService, times(1)).getFeedActivities(userId); - } - - // MARK: - Edge Case Tests - - @Test - void createActivity_ShouldHandleLargeParticipantList_WhenManyInvites() throws Exception { - List largeInviteList = new ArrayList<>(); - for (int i = 0; i < 100; i++) { - largeInviteList.add(UUID.randomUUID()); - } - - ActivityDTO largeActivity = new ActivityDTO( - null, "Large Activity", OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), locationDTO, null, - "Large note", "🎉", 100, creatorId, List.of(), largeInviteList, List.of(), - Instant.now(), false, "America/New_York" - ); - - when(activityService.createActivityWithSuggestions(any(ActivityDTO.class))) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(post("/api/v1/activities") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(largeActivity))) - .andExpect(status().isCreated()); - - verify(activityService, times(1)).createActivityWithSuggestions(any(ActivityDTO.class)); - } - - @Test - void partialUpdateActivity_ShouldHandleMultipleFieldUpdates_WhenComplexUpdate() throws Exception { - ActivityPartialUpdateDTO updates = new ActivityPartialUpdateDTO(); - updates.setTitle("New Title"); - updates.setNote("New Note"); - updates.setParticipantLimit(10); - - when(activityService.partialUpdateActivity(any(ActivityPartialUpdateDTO.class), eq(activityId))) - .thenReturn(fullFeedActivityDTO); - - mockMvc.perform(patch("/api/v1/activities/{id}/partial", activityId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(updates))) - .andExpect(status().isOk()); - - verify(activityService, times(1)).partialUpdateActivity(any(ActivityPartialUpdateDTO.class), eq(activityId)); - } - - @Test - void getFullActivityById_ShouldReturnBadRequest_WhenMissingRequestingUserId() throws Exception { - mockMvc.perform(get("/api/v1/activities/{id}", activityId)) - .andExpect(status().isBadRequest()); - - verify(activityService, never()).getFullActivityById(any(), any()); - } - - @Test - void getActivitiesCreatedByUserId_ShouldReturnActivities_WhenUserHasActivities() throws Exception { - List activities = List.of(fullFeedActivityDTO); - when(activityService.getActivitiesByOwnerId(creatorId)).thenReturn(List.of(activityDTO)); - when(activityService.convertActivitiesToFullFeedSelfOwnedActivities(any(), eq(creatorId))) - .thenReturn(activities); - - mockMvc.perform(get("/api/v1/activities/user/{creatorUserId}", creatorId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)); - - verify(activityService, times(1)).getActivitiesByOwnerId(creatorId); - } -} diff --git a/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityTypeControllerTests.java b/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityTypeControllerTests.java deleted file mode 100644 index b0329c3a4..000000000 --- a/src/test/java/com/danielagapov/spawn/ControllerTests/ActivityTypeControllerTests.java +++ /dev/null @@ -1,545 +0,0 @@ -package com.danielagapov.spawn.ControllerTests; - -import com.danielagapov.spawn.activity.api.ActivityTypeController; -import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.activity.api.dto.BatchActivityTypeUpdateDTO; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.exceptions.ActivityTypeValidationException; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Comprehensive unit tests for ActivityTypeController - * Tests all API endpoints that the front-end uses for activity type management - */ -@ExtendWith(MockitoExtension.class) -class ActivityTypeControllerTests { - - @Mock - private IActivityTypeService activityTypeService; - - @Mock - private ILogger logger; - - @InjectMocks - private ActivityTypeController activityTypeController; - - private MockMvc mockMvc; - private ObjectMapper objectMapper; - private UUID userId; - private UUID activityTypeId1; - private UUID activityTypeId2; - private ActivityTypeDTO chillActivityTypeDTO; - private ActivityTypeDTO foodActivityTypeDTO; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - // Configure MockMvc with proper JSON message converter - mockMvc = MockMvcBuilders.standaloneSetup(activityTypeController) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .build(); - - userId = UUID.randomUUID(); - activityTypeId1 = UUID.randomUUID(); - activityTypeId2 = UUID.randomUUID(); - - chillActivityTypeDTO = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - "🛋️", - 0, - userId, - false - ); - - foodActivityTypeDTO = new ActivityTypeDTO( - activityTypeId2, - "Food", - List.of(), - "🍽️", - 1, - userId, - true - ); - } - - // MARK: - GET Activity Types Tests - - @Test - void getOwnedActivityTypesForUser_ShouldReturnActivityTypes_WhenUserHasActivityTypes() throws Exception { - // Arrange - List activityTypes = List.of(chillActivityTypeDTO, foodActivityTypeDTO); - when(activityTypeService.getActivityTypesByUserId(userId)).thenReturn(activityTypes); - - // Act & Assert - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", userId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].id").value(activityTypeId1.toString())) - .andExpect(jsonPath("$[0].title").value("Chill")) - .andExpect(jsonPath("$[0].icon").value("🛋️")) - .andExpect(jsonPath("$[0].orderNum").value(0)) - .andExpect(jsonPath("$[0].isPinned").value(false)) - .andExpect(jsonPath("$[1].id").value(activityTypeId2.toString())) - .andExpect(jsonPath("$[1].title").value("Food")) - .andExpect(jsonPath("$[1].isPinned").value(true)); - - verify(activityTypeService, times(1)).getActivityTypesByUserId(userId); - verify(logger, times(1)).info(contains("Fetching owned activity types for user")); - } - - @Test - void getOwnedActivityTypesForUser_ShouldReturnEmptyList_WhenUserHasNoActivityTypes() throws Exception { - // Arrange - when(activityTypeService.getActivityTypesByUserId(userId)).thenReturn(List.of()); - - // Act & Assert - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", userId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(0)); - - verify(activityTypeService, times(1)).getActivityTypesByUserId(userId); - } - - @Test - void getOwnedActivityTypesForUser_ShouldReturnInternalServerError_WhenServiceThrowsException() throws Exception { - // Arrange - when(activityTypeService.getActivityTypesByUserId(userId)) - .thenThrow(new RuntimeException("Database connection failed")); - - // Act & Assert - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", userId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error fetching owned activity types")); - } - - @Test - void getOwnedActivityTypesForUser_ShouldHandleInvalidUserId_WhenUserIdIsNotUUID() throws Exception { - // Act & Assert - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", "invalid-uuid")) - .andExpect(status().isBadRequest()); - } - - // MARK: - PUT Batch Update Tests - - @Test - void batchUpdateActivityTypes_ShouldReturnUpdatedActivityTypes_WhenValidBatchUpdate() throws Exception { - // Arrange - ActivityTypeDTO updatedChillDTO = new ActivityTypeDTO( - activityTypeId1, "Chill Updated", List.of(), "🛋️", 0, userId, true - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(updatedChillDTO), - List.of() - ); - - List updatedActivityTypes = List.of(updatedChillDTO, foodActivityTypeDTO); - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(updatedActivityTypes); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].title").value("Chill Updated")) - .andExpect(jsonPath("$[0].isPinned").value(true)); - - verify(activityTypeService, times(1)).updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class)); - verify(logger, times(1)).info(contains("Batch updating activity types for user")); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleCreationAndDeletion_WhenComplexBatchUpdate() throws Exception { - // Arrange - Create new, update existing, delete one - UUID newActivityTypeId = UUID.randomUUID(); - ActivityTypeDTO newActivityTypeDTO = new ActivityTypeDTO( - newActivityTypeId, "Study", List.of(), "📚", 2, userId, false - ); - - ActivityTypeDTO updatedChillDTO = new ActivityTypeDTO( - activityTypeId1, "Chill & Relax", List.of(), "🛋️", 0, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newActivityTypeDTO, updatedChillDTO), - List.of(activityTypeId2) // Delete food activity type - ); - - List resultActivityTypes = List.of(updatedChillDTO, newActivityTypeDTO); - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(resultActivityTypes); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(2)) - .andExpect(jsonPath("$[0].title").value("Chill & Relax")) - .andExpect(jsonPath("$[1].title").value("Study")); - - verify(activityTypeService, times(1)).updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class)); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleReordering_WhenActivityTypesReordered() throws Exception { - // Arrange - Reorder existing activity types - ActivityTypeDTO reorderedChillDTO = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false // Moved from 0 to 1 - ); - ActivityTypeDTO reorderedFoodDTO = new ActivityTypeDTO( - activityTypeId2, "Food", List.of(), "🍽️", 0, userId, true // Moved from 1 to 0 - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(reorderedChillDTO, reorderedFoodDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(List.of(reorderedFoodDTO, reorderedChillDTO)); // Sorted by order - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].orderNum").value(0)) - .andExpect(jsonPath("$[1].orderNum").value(1)); - } - - @Test - void batchUpdateActivityTypes_ShouldHandlePinToggling_WhenUserTogglesPins() throws Exception { - // Arrange - Toggle pin status - ActivityTypeDTO pinnedChillDTO = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 0, userId, true // Toggled to pinned - ); - ActivityTypeDTO unpinnedFoodDTO = new ActivityTypeDTO( - activityTypeId2, "Food", List.of(), "🍽️", 1, userId, false // Toggled to unpinned - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(pinnedChillDTO, unpinnedFoodDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(List.of(pinnedChillDTO, unpinnedFoodDTO)); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].isPinned").value(true)) - .andExpect(jsonPath("$[1].isPinned").value(false)); - } - - @Test - void batchUpdateActivityTypes_ShouldReturnInternalServerError_WhenServiceThrowsException() throws Exception { - // Arrange - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(chillActivityTypeDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenThrow(new RuntimeException("Database error")); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error batch updating activity types")); - } - - @Test - void batchUpdateActivityTypes_ShouldReturnInternalServerError_WhenValidationException() throws Exception { - // Arrange - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(chillActivityTypeDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenThrow(new ActivityTypeValidationException("Too many pinned activity types")); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isInternalServerError()); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleMalformedJson_WhenInvalidJsonRequest() throws Exception { - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content("{invalid json")) - .andExpect(status().isBadRequest()); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleInvalidUserId_WhenUserIdIsNotUUID() throws Exception { - // Arrange - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO(List.of(), List.of()); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", "invalid-uuid") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isBadRequest()); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleEmptyBatch_WhenNoChanges() throws Exception { - // Arrange - BatchActivityTypeUpdateDTO emptyBatchDTO = new BatchActivityTypeUpdateDTO(List.of(), List.of()); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenThrow(new IllegalArgumentException("No activity types to update or delete")); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(emptyBatchDTO))) - .andExpect(status().isInternalServerError()); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleMissingRequestBody_WhenNoBodyProvided() throws Exception { - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON)) - .andExpect(status().isBadRequest()); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleNullFieldsInDTO_WhenPartialData() throws Exception { - // Arrange - Test with null fields in the DTO - String jsonWithNulls = """ - { - "updatedActivityTypes": [ - { - "id": "%s", - "title": null, - "associatedFriends": null, - "icon": "🛋️", - "orderNum": 0, - "ownerUserId": "%s", - "isPinned": false - } - ], - "deletedActivityTypeIds": [] - } - """.formatted(activityTypeId1, userId); - - // Mock service to handle the null fields appropriately - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(List.of(chillActivityTypeDTO)); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(jsonWithNulls)) - .andExpect(status().isOk()); - } - - // MARK: - Direct Controller Method Tests - - @Test - void getOwnedActivityTypesForUser_DirectCall_ShouldReturnOkResponse_WhenServiceSucceeds() { - // Arrange - List activityTypes = List.of(chillActivityTypeDTO, foodActivityTypeDTO); - when(activityTypeService.getActivityTypesByUserId(userId)).thenReturn(activityTypes); - - // Act - ResponseEntity> response = - activityTypeController.getOwnedActivityTypesForUser(userId); - - // Assert - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(2, response.getBody().size()); - assertEquals("Chill", response.getBody().get(0).getTitle()); - verify(activityTypeService, times(1)).getActivityTypesByUserId(userId); - } - - @Test - void getOwnedActivityTypesForUser_DirectCall_ShouldReturnInternalServerError_WhenServiceFails() { - // Arrange - when(activityTypeService.getActivityTypesByUserId(userId)) - .thenThrow(new RuntimeException("Service error")); - - // Act - ResponseEntity> response = - activityTypeController.getOwnedActivityTypesForUser(userId); - - // Assert - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - assertNull(response.getBody()); - verify(logger, times(1)).error(contains("Error fetching owned activity types")); - } - - @Test - void batchUpdateActivityTypes_DirectCall_ShouldReturnOkResponse_WhenServiceSucceeds() { - // Arrange - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(chillActivityTypeDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(userId, batchDTO)) - .thenReturn(List.of(chillActivityTypeDTO)); - - // Act - ResponseEntity> response = - activityTypeController.updateActivityTypes(userId, batchDTO); - - // Assert - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(1, response.getBody().size()); - verify(activityTypeService, times(1)).updateActivityTypes(userId, batchDTO); - } - - @Test - void batchUpdateActivityTypes_DirectCall_ShouldReturnInternalServerError_WhenServiceFails() { - // Arrange - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(chillActivityTypeDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(userId, batchDTO)) - .thenThrow(new RuntimeException("Service error")); - - // Act - ResponseEntity> response = - activityTypeController.updateActivityTypes(userId, batchDTO); - - // Assert - assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); - assertNull(response.getBody()); - verify(logger, times(1)).error(contains("Error batch updating activity types")); - } - - // MARK: - Edge Case Tests - - @Test - void batchUpdateActivityTypes_ShouldHandleLargeDataset_WhenManyActivityTypes() throws Exception { - // Arrange - Test with many activity types (simulating user with lots of types) - List manyActivityTypes = new ArrayList<>(); - List manyUpdatedTypes = new ArrayList<>(); - - for (int i = 0; i < 50; i++) { - UUID id = UUID.randomUUID(); - ActivityTypeDTO dto = new ActivityTypeDTO( - id, "Type " + i, List.of(), "🎯", i, userId, i % 5 == 0 // Every 5th is pinned - ); - manyActivityTypes.add(dto); - manyUpdatedTypes.add(dto); - } - - BatchActivityTypeUpdateDTO largeBatchDTO = new BatchActivityTypeUpdateDTO( - manyActivityTypes, - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(manyUpdatedTypes); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(largeBatchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(50)); - } - - @Test - void batchUpdateActivityTypes_ShouldHandleSpecialCharacters_WhenTitleContainsUnicode() throws Exception { - // Arrange - Test with special characters and emojis - ActivityTypeDTO specialCharDTO = new ActivityTypeDTO( - activityTypeId1, - "🎉 Party & Fun 🎊 (Special Event)", - List.of(), - "🎉", - 0, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(specialCharDTO), - List.of() - ); - - when(activityTypeService.updateActivityTypes(eq(userId), any(BatchActivityTypeUpdateDTO.class))) - .thenReturn(List.of(specialCharDTO)); - - // Act & Assert - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("🎉 Party & Fun 🎊 (Special Event)")) - .andExpect(jsonPath("$[0].icon").value("🎉")); - } - - @Test - void getOwnedActivityTypes_ShouldHandleConcurrentRequests_WhenMultipleRequestsSimultaneously() { - // Arrange - Simulate concurrent requests from front-end - List activityTypes = List.of(chillActivityTypeDTO, foodActivityTypeDTO); - when(activityTypeService.getActivityTypesByUserId(userId)).thenReturn(activityTypes); - - // Act - Make multiple concurrent calls - ResponseEntity> response1 = - activityTypeController.getOwnedActivityTypesForUser(userId); - ResponseEntity> response2 = - activityTypeController.getOwnedActivityTypesForUser(userId); - ResponseEntity> response3 = - activityTypeController.getOwnedActivityTypesForUser(userId); - - // Assert - All should succeed - assertEquals(HttpStatus.OK, response1.getStatusCode()); - assertEquals(HttpStatus.OK, response2.getStatusCode()); - assertEquals(HttpStatus.OK, response3.getStatusCode()); - - // Verify service was called for each request - verify(activityTypeService, times(3)).getActivityTypesByUserId(userId); - } -} \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/ControllerTests/CalendarControllerTests.java b/src/test/java/com/danielagapov/spawn/ControllerTests/CalendarControllerTests.java deleted file mode 100644 index 5f14ec4cd..000000000 --- a/src/test/java/com/danielagapov/spawn/ControllerTests/CalendarControllerTests.java +++ /dev/null @@ -1,447 +0,0 @@ -package com.danielagapov.spawn.ControllerTests; - -import com.danielagapov.spawn.user.api.CalendarController; -import com.danielagapov.spawn.activity.api.dto.CalendarActivityDTO; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.activity.internal.services.ICalendarService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Comprehensive unit tests for CalendarController - * Tests calendar activity retrieval with various filters - */ -@ExtendWith(MockitoExtension.class) -@DisplayName("Calendar Controller Tests") -class CalendarControllerTests { - - @Mock - private ICalendarService calendarService; - - @Mock - private ILogger logger; - - @InjectMocks - private CalendarController calendarController; - - private MockMvc mockMvc; - private ObjectMapper objectMapper; - private UUID userId; - private UUID activityId; - private List calendarActivities; - - /** - * Helper method to create CalendarActivityDTO with correct constructor order: - * (id, date, title, icon, colorHexCode, activityId) - */ - private CalendarActivityDTO createCalendarActivity(String title, LocalDate date, String icon) { - return new CalendarActivityDTO( - UUID.randomUUID(), // id - date.toString(), // date as ISO string - title, // title - icon, // icon - "#FF5733", // colorHexCode - activityId // activityId - ); - } - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - - mockMvc = MockMvcBuilders.standaloneSetup(calendarController) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .build(); - - userId = UUID.randomUUID(); - activityId = UUID.randomUUID(); - - calendarActivities = List.of( - createCalendarActivity("Activity 1", LocalDate.now().plusDays(1), "🎉"), - createCalendarActivity("Activity 2", LocalDate.now().plusDays(2), "🍽️"), - createCalendarActivity("Activity 3", LocalDate.now().plusDays(3), "⚽") - ); - - // Setup lenient logger mocks - lenient().doNothing().when(logger).info(anyString()); - lenient().doNothing().when(logger).warn(anyString()); - lenient().doNothing().when(logger).error(anyString()); - } - - @Nested - @DisplayName("Get Calendar Activities Tests") - class GetCalendarActivitiesTests { - - @Test - @DisplayName("Should return activities when no filters") - void shouldReturnActivities_WhenNoFilters() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(calendarActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(3)) - .andExpect(jsonPath("$[0].title").value("Activity 1")) - .andExpect(jsonPath("$[1].title").value("Activity 2")) - .andExpect(jsonPath("$[2].title").value("Activity 3")); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - verify(logger, times(1)).info(contains("Calendar API called")); - verify(logger, times(1)).info(contains("Successfully retrieved 3 calendar activities")); - } - - @Test - @DisplayName("Should return filtered activities when month provided") - void shouldReturnFilteredActivities_WhenMonthProvided() throws Exception { - List januaryActivities = List.of( - createCalendarActivity("January Activity", LocalDate.of(2024, 1, 15), "🎉") - ); - - when(calendarService.getCalendarActivitiesWithFilters(userId, 1, null)) - .thenReturn(januaryActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("month", "1")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("January Activity")); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, 1, null); - verify(logger, times(1)).info(contains("month: 1")); - } - - @Test - @DisplayName("Should return filtered activities when year provided") - void shouldReturnFilteredActivities_WhenYearProvided() throws Exception { - List yearActivities = List.of( - createCalendarActivity("2024 Activity", LocalDate.of(2024, 6, 15), "🎉") - ); - - when(calendarService.getCalendarActivitiesWithFilters(userId, null, 2024)) - .thenReturn(yearActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("year", "2024")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("2024 Activity")); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, 2024); - verify(logger, times(1)).info(contains("year: 2024")); - } - - @Test - @DisplayName("Should return filtered activities when month and year provided") - void shouldReturnFilteredActivities_WhenMonthAndYearProvided() throws Exception { - List specificMonthActivities = List.of( - createCalendarActivity("December 2024 Activity", LocalDate.of(2024, 12, 25), "🎄") - ); - - when(calendarService.getCalendarActivitiesWithFilters(userId, 12, 2024)) - .thenReturn(specificMonthActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("month", "12") - .param("year", "2024")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].title").value("December 2024 Activity")); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, 12, 2024); - verify(logger, times(1)).info(contains("month: 12")); - verify(logger, times(1)).info(contains("year: 2024")); - } - - @Test - @DisplayName("Should return empty list when no activities found") - void shouldReturnEmptyList_WhenNoActivitiesFound() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - verify(logger, times(1)).warn(contains("No calendar activities found")); - } - - @Test - @DisplayName("Should return bad request when invalid user ID") - void shouldReturnBadRequest_WhenInvalidUserId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 404 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(get("/api/v1/users/invalid-uuid/calendar")) - .andExpect(status().isBadRequest()); - - verify(calendarService, never()).getCalendarActivitiesWithFilters(any(), any(), any()); - } - - @Test - @DisplayName("Should return not found when user not found") - void shouldReturnNotFound_WhenUserNotFound() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenThrow(new BaseNotFoundException(EntityType.User, userId)); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("User not found")); - } - - @Test - @DisplayName("Should return internal server error when service fails") - void shouldReturnInternalServerError_WhenServiceFails() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenThrow(new RuntimeException("Database error")); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error getting calendar activities")); - } - } - - @Nested - @DisplayName("Get All Calendar Activities Tests") - class GetAllCalendarActivitiesTests { - - @Test - @DisplayName("Should return all activities when user has activities") - void shouldReturnAllActivities_WhenUserHasActivities() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(calendarActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar/all", userId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(3)); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - } - - @Test - @DisplayName("Should return empty list when no activities") - void shouldReturnEmptyList_WhenNoActivities() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar/all", userId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - } - - @Test - @DisplayName("Should return bad request when invalid user ID") - void shouldReturnBadRequest_WhenInvalidUserId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 404 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(get("/api/v1/users/invalid-uuid/calendar/all")) - .andExpect(status().isBadRequest()); - - verify(calendarService, never()).getCalendarActivitiesWithFilters(any(), any(), any()); - } - - @Test - @DisplayName("Should return not found when user not found") - void shouldReturnNotFound_WhenUserNotFound() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenThrow(new BaseNotFoundException(EntityType.User, userId)); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar/all", userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("User not found")); - } - } - - @Nested - @DisplayName("Direct Controller Method Tests") - class DirectControllerMethodTests { - - @Test - @DisplayName("Get calendar activities direct call") - void getCalendarActivities_DirectCall() { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(calendarActivities); - - ResponseEntity> response = - calendarController.getCalendarActivities(userId, null, null); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(3, response.getBody().size()); - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - } - - @Test - @DisplayName("Get calendar activities direct call with null user ID") - void getCalendarActivities_DirectCall_NullUserId() { - ResponseEntity> response = - calendarController.getCalendarActivities(null, null, null); - - assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); - verify(calendarService, never()).getCalendarActivitiesWithFilters(any(), any(), any()); - } - - @Test - @DisplayName("Get all calendar activities direct call") - void getAllCalendarActivities_DirectCall() { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(calendarActivities); - - ResponseEntity> response = - calendarController.getAllCalendarActivities(userId); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(3, response.getBody().size()); - } - } - - @Nested - @DisplayName("Edge Case Tests") - class EdgeCaseTests { - - @Test - @DisplayName("Should handle large result set") - void shouldHandleLargeResult() throws Exception { - List manyActivities = new ArrayList<>(); - for (int i = 0; i < 100; i++) { - manyActivities.add(createCalendarActivity("Activity " + i, LocalDate.now().plusDays(i), "🎉")); - } - - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(manyActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(100)); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, null); - verify(logger, times(1)).info(contains("Successfully retrieved 100 calendar activities")); - } - - @Test - @DisplayName("Should handle invalid month") - void shouldHandleInvalidMonth() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, 13, null)) - .thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("month", "13")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, 13, null); - } - - @Test - @DisplayName("Should handle negative month") - void shouldHandleNegativeMonth() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, -1, null)) - .thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("month", "-1")) - .andExpect(status().isOk()); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, -1, null); - } - - @Test - @DisplayName("Should handle different years") - void shouldHandleDifferentYears() throws Exception { - List activities2023 = List.of( - createCalendarActivity("2023 Activity", LocalDate.of(2023, 6, 15), "🎉") - ); - List activities2024 = List.of( - createCalendarActivity("2024 Activity", LocalDate.of(2024, 6, 15), "🎊") - ); - - when(calendarService.getCalendarActivitiesWithFilters(userId, null, 2023)) - .thenReturn(activities2023); - when(calendarService.getCalendarActivitiesWithFilters(userId, null, 2024)) - .thenReturn(activities2024); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("year", "2023")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("2023 Activity")); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("year", "2024")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].title").value("2024 Activity")); - - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, 2023); - verify(calendarService, times(1)).getCalendarActivitiesWithFilters(userId, null, 2024); - } - - @Test - @DisplayName("Should handle all months") - void shouldHandleAllMonths() throws Exception { - for (int month = 1; month <= 12; month++) { - List monthActivities = List.of( - createCalendarActivity("Month " + month + " Activity", LocalDate.of(2024, month, 15), "🎉") - ); - - when(calendarService.getCalendarActivitiesWithFilters(userId, month, null)) - .thenReturn(monthActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId) - .param("month", String.valueOf(month))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(1)); - } - - verify(calendarService, times(12)).getCalendarActivitiesWithFilters(eq(userId), anyInt(), isNull()); - } - - @Test - @DisplayName("Should handle concurrent requests") - void shouldHandleConcurrentRequests() throws Exception { - when(calendarService.getCalendarActivitiesWithFilters(userId, null, null)) - .thenReturn(calendarActivities); - - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()); - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()); - mockMvc.perform(get("/api/v1/users/{userId}/calendar", userId)) - .andExpect(status().isOk()); - - verify(calendarService, times(3)).getCalendarActivitiesWithFilters(userId, null, null); - } - } -} diff --git a/src/test/java/com/danielagapov/spawn/ControllerTests/ChatMessageControllerTests.java b/src/test/java/com/danielagapov/spawn/ControllerTests/ChatMessageControllerTests.java deleted file mode 100644 index 9177610e6..000000000 --- a/src/test/java/com/danielagapov/spawn/ControllerTests/ChatMessageControllerTests.java +++ /dev/null @@ -1,519 +0,0 @@ -package com.danielagapov.spawn.ControllerTests; - -import com.danielagapov.spawn.chat.api.ChatMessageController; -import com.danielagapov.spawn.chat.api.dto.*; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.chat.internal.services.IChatMessageService; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.time.Instant; -import java.util.List; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Comprehensive unit tests for ChatMessageController - * Tests chat message creation, deletion, and likes functionality - */ -@ExtendWith(MockitoExtension.class) -class ChatMessageControllerTests { - - @Mock - private IChatMessageService chatMessageService; - - @Mock - private ILogger logger; - - @InjectMocks - private ChatMessageController chatMessageController; - - private MockMvc mockMvc; - private ObjectMapper objectMapper; - private UUID chatMessageId; - private UUID userId; - private UUID activityId; - private CreateChatMessageDTO createChatMessageDTO; - private FullActivityChatMessageDTO fullActivityChatMessageDTO; - private ChatMessageLikesDTO chatMessageLikesDTO; - private BaseUserDTO baseUserDTO; - - @BeforeEach - void setUp() { - objectMapper = new ObjectMapper(); - objectMapper.registerModule(new JavaTimeModule()); - - mockMvc = MockMvcBuilders.standaloneSetup(chatMessageController) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .build(); - - chatMessageId = UUID.randomUUID(); - userId = UUID.randomUUID(); - activityId = UUID.randomUUID(); - - createChatMessageDTO = new CreateChatMessageDTO( - "Test message content", - activityId, - userId - ); - - // BaseUserDTO constructor: (id, name, email, username, bio, profilePicture) - baseUserDTO = new BaseUserDTO(userId, "Test User", "test@example.com", "testuser", "bio", "pic.jpg"); - - // FullActivityChatMessageDTO constructor: (id, content, timestamp, senderUser, activityId, likedByUsers) - fullActivityChatMessageDTO = new FullActivityChatMessageDTO( - chatMessageId, - "Test message content", - Instant.now(), - baseUserDTO, - activityId, - List.of() - ); - - // ChatMessageLikesDTO constructor: (chatMessageId, userId) - chatMessageLikesDTO = new ChatMessageLikesDTO( - chatMessageId, - userId - ); - } - - // MARK: - Create Chat Message Tests - - @Test - void createChatMessage_ShouldReturnCreated_WhenValidMessage() throws Exception { - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(fullActivityChatMessageDTO); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createChatMessageDTO))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.id").value(chatMessageId.toString())) - .andExpect(jsonPath("$.content").value("Test message content")) - .andExpect(jsonPath("$.activityId").value(activityId.toString())); - - verify(chatMessageService, times(1)).createChatMessage(any(CreateChatMessageDTO.class)); - } - - @Test - void createChatMessage_ShouldReturnInternalServerError_WhenServiceFails() throws Exception { - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenThrow(new RuntimeException("Database error")); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createChatMessageDTO))) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error creating chat message")); - } - - @Test - void createChatMessage_ShouldHandleEmptyContent_WhenContentIsEmpty() throws Exception { - CreateChatMessageDTO emptyContentDTO = new CreateChatMessageDTO("", activityId, userId); - - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(fullActivityChatMessageDTO); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(emptyContentDTO))) - .andExpect(status().isCreated()); - - verify(chatMessageService, times(1)).createChatMessage(any(CreateChatMessageDTO.class)); - } - - @Test - void createChatMessage_ShouldHandleLongContent_WhenContentIsVeryLong() throws Exception { - String longContent = "a".repeat(1000); - CreateChatMessageDTO longContentDTO = new CreateChatMessageDTO(longContent, activityId, userId); - FullActivityChatMessageDTO longMessageResponse = new FullActivityChatMessageDTO( - chatMessageId, longContent, Instant.now(), baseUserDTO, activityId, List.of() - ); - - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(longMessageResponse); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(longContentDTO))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.content").value(longContent)); - - verify(chatMessageService, times(1)).createChatMessage(any(CreateChatMessageDTO.class)); - } - - // MARK: - Delete Chat Message Tests - - @Test - void deleteChatMessage_ShouldReturnNoContent_WhenSuccessful() throws Exception { - when(chatMessageService.deleteChatMessageById(chatMessageId)).thenReturn(true); - - mockMvc.perform(delete("/api/v1/chat-messages/{id}", chatMessageId)) - .andExpect(status().isNoContent()); - - verify(chatMessageService, times(1)).deleteChatMessageById(chatMessageId); - } - - @Test - void deleteChatMessage_ShouldReturnBadRequest_WhenInvalidId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 405 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(delete("/api/v1/chat-messages/invalid-uuid")) - .andExpect(status().isBadRequest()); - - verify(chatMessageService, never()).deleteChatMessageById(any()); - } - - @Test - void deleteChatMessage_ShouldReturnNotFound_WhenMessageNotFound() throws Exception { - when(chatMessageService.deleteChatMessageById(chatMessageId)) - .thenThrow(new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); - - mockMvc.perform(delete("/api/v1/chat-messages/{id}", chatMessageId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Chat message not found for deletion")); - } - - @Test - void deleteChatMessage_ShouldReturnInternalServerError_WhenDeletionFails() throws Exception { - when(chatMessageService.deleteChatMessageById(chatMessageId)).thenReturn(false); - - mockMvc.perform(delete("/api/v1/chat-messages/{id}", chatMessageId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Failed to delete chat message")); - } - - @Test - void deleteChatMessage_ShouldReturnInternalServerError_WhenExceptionThrown() throws Exception { - when(chatMessageService.deleteChatMessageById(chatMessageId)) - .thenThrow(new RuntimeException("Unexpected error")); - - mockMvc.perform(delete("/api/v1/chat-messages/{id}", chatMessageId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error deleting chat message")); - } - - // MARK: - Create Chat Message Like Tests - - @Test - void createChatMessageLike_ShouldReturnCreated_WhenSuccessful() throws Exception { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenReturn(chatMessageLikesDTO); - - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.chatMessageId").value(chatMessageId.toString())) - .andExpect(jsonPath("$.userId").value(userId.toString())); - - verify(chatMessageService, times(1)).createChatMessageLike(chatMessageId, userId); - } - - @Test - void createChatMessageLike_ShouldReturnBadRequest_WhenInvalidUserId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 405 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/invalid-uuid", chatMessageId)) - .andExpect(status().isBadRequest()); - - verify(chatMessageService, never()).createChatMessageLike(any(), any()); - } - - @Test - void createChatMessageLike_ShouldReturnNotFound_WhenMessageNotFound() throws Exception { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenThrow(new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); - - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Chat message or user not found")); - } - - @Test - void createChatMessageLike_ShouldReturnNotFound_WhenUserNotFound() throws Exception { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenThrow(new BaseNotFoundException(EntityType.User, userId)); - - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Chat message or user not found")); - } - - @Test - void createChatMessageLike_ShouldReturnInternalServerError_WhenServiceFails() throws Exception { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenThrow(new RuntimeException("Database error")); - - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error creating chat message like")); - } - - // MARK: - Get Chat Message Likes Tests - - @Test - void getChatMessageLikes_ShouldReturnLikes_WhenLikesExist() throws Exception { - List likes = List.of(baseUserDTO); - when(chatMessageService.getChatMessageLikes(chatMessageId)).thenReturn(likes); - - mockMvc.perform(get("/api/v1/chat-messages/{chatMessageId}/likes", chatMessageId)) - .andExpect(status().isOk()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.length()").value(1)) - .andExpect(jsonPath("$[0].id").value(userId.toString())); - - verify(chatMessageService, times(1)).getChatMessageLikes(chatMessageId); - } - - @Test - void getChatMessageLikes_ShouldReturnEmptyList_WhenNoLikes() throws Exception { - when(chatMessageService.getChatMessageLikes(chatMessageId)).thenReturn(List.of()); - - mockMvc.perform(get("/api/v1/chat-messages/{chatMessageId}/likes", chatMessageId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(0)); - - verify(chatMessageService, times(1)).getChatMessageLikes(chatMessageId); - } - - @Test - void getChatMessageLikes_ShouldReturnBadRequest_WhenInvalidId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 404 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(get("/api/v1/chat-messages/invalid-uuid/likes")) - .andExpect(status().isBadRequest()); - - verify(chatMessageService, never()).getChatMessageLikes(any()); - } - - @Test - void getChatMessageLikes_ShouldReturnNotFound_WhenMessageNotFound() throws Exception { - when(chatMessageService.getChatMessageLikes(chatMessageId)) - .thenThrow(new BaseNotFoundException(EntityType.ChatMessage, chatMessageId)); - - mockMvc.perform(get("/api/v1/chat-messages/{chatMessageId}/likes", chatMessageId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Chat message not found for likes retrieval")); - } - - @Test - void getChatMessageLikes_ShouldReturnInternalServerError_WhenServiceFails() throws Exception { - when(chatMessageService.getChatMessageLikes(chatMessageId)) - .thenThrow(new RuntimeException("Database error")); - - mockMvc.perform(get("/api/v1/chat-messages/{chatMessageId}/likes", chatMessageId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error getting chat message likes")); - } - - // MARK: - Delete Chat Message Like Tests - - @Test - void deleteChatMessageLike_ShouldReturnNoContent_WhenSuccessful() throws Exception { - doNothing().when(chatMessageService).deleteChatMessageLike(chatMessageId, userId); - - mockMvc.perform(delete("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isNoContent()); - - verify(chatMessageService, times(1)).deleteChatMessageLike(chatMessageId, userId); - } - - @Test - void deleteChatMessageLike_ShouldReturnBadRequest_WhenInvalidUserId() throws Exception { - // Note: Passing null to a path variable in MockMvc results in a malformed URL, - // which Spring interprets as a 405 (not a 400 from our controller logic). - // Testing with an invalid UUID format instead. - mockMvc.perform(delete("/api/v1/chat-messages/{chatMessageId}/likes/invalid-uuid", chatMessageId)) - .andExpect(status().isBadRequest()); - - verify(chatMessageService, never()).deleteChatMessageLike(any(), any()); - } - - @Test - void deleteChatMessageLike_ShouldReturnNotFound_WhenLikeNotFound() throws Exception { - doThrow(new BaseNotFoundException(EntityType.ChatMessageLike, chatMessageId)) - .when(chatMessageService).deleteChatMessageLike(chatMessageId, userId); - - mockMvc.perform(delete("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isNotFound()); - - verify(logger, times(1)).error(contains("Chat message like not found for deletion")); - } - - @Test - void deleteChatMessageLike_ShouldReturnInternalServerError_WhenServiceFails() throws Exception { - doThrow(new RuntimeException("Database error")) - .when(chatMessageService).deleteChatMessageLike(chatMessageId, userId); - - mockMvc.perform(delete("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error deleting chat message like")); - } - - // MARK: - Direct Controller Method Tests - - @Test - void createChatMessage_DirectCall_ShouldReturnCreated_WhenSuccessful() { - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(fullActivityChatMessageDTO); - - ResponseEntity response = - chatMessageController.createChatMessage(createChatMessageDTO); - - assertEquals(HttpStatus.CREATED, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(chatMessageId, response.getBody().getId()); - verify(chatMessageService, times(1)).createChatMessage(any(CreateChatMessageDTO.class)); - } - - @Test - void deleteChatMessage_DirectCall_ShouldReturnNoContent_WhenSuccessful() { - when(chatMessageService.deleteChatMessageById(chatMessageId)).thenReturn(true); - - ResponseEntity response = chatMessageController.deleteChatMessage(chatMessageId); - - assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); - verify(chatMessageService, times(1)).deleteChatMessageById(chatMessageId); - } - - @Test - void createChatMessageLike_DirectCall_ShouldReturnCreated_WhenSuccessful() { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenReturn(chatMessageLikesDTO); - - ResponseEntity response = - chatMessageController.createChatMessageLike(chatMessageId, userId); - - assertEquals(HttpStatus.CREATED, response.getStatusCode()); - assertNotNull(response.getBody()); - verify(chatMessageService, times(1)).createChatMessageLike(chatMessageId, userId); - } - - @Test - void getChatMessageLikes_DirectCall_ShouldReturnOk_WhenLikesExist() { - List likes = List.of(baseUserDTO); - when(chatMessageService.getChatMessageLikes(chatMessageId)).thenReturn(likes); - - ResponseEntity> response = - chatMessageController.getChatMessageLikes(chatMessageId); - - assertEquals(HttpStatus.OK, response.getStatusCode()); - assertNotNull(response.getBody()); - assertEquals(1, response.getBody().size()); - verify(chatMessageService, times(1)).getChatMessageLikes(chatMessageId); - } - - @Test - void deleteChatMessageLike_DirectCall_ShouldReturnNoContent_WhenSuccessful() { - doNothing().when(chatMessageService).deleteChatMessageLike(chatMessageId, userId); - - ResponseEntity response = chatMessageController.deleteChatMessageLike(chatMessageId, userId); - - assertEquals(HttpStatus.NO_CONTENT, response.getStatusCode()); - verify(chatMessageService, times(1)).deleteChatMessageLike(chatMessageId, userId); - } - - // MARK: - Edge Case Tests - - @Test - void createChatMessage_ShouldHandleSpecialCharacters_WhenContentHasSpecialChars() throws Exception { - String specialContent = "Test 🎉 message with émojis & spëcial çhars!"; - CreateChatMessageDTO specialDTO = new CreateChatMessageDTO(specialContent, activityId, userId); - FullActivityChatMessageDTO specialResponse = new FullActivityChatMessageDTO( - chatMessageId, specialContent, Instant.now(), baseUserDTO, activityId, List.of() - ); - - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(specialResponse); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(specialDTO))) - .andExpect(status().isCreated()) - .andExpect(jsonPath("$.content").value(specialContent)); - - verify(chatMessageService, times(1)).createChatMessage(any(CreateChatMessageDTO.class)); - } - - @Test - void getChatMessageLikes_ShouldHandleMultipleLikes_WhenManyUsersLiked() throws Exception { - // BaseUserDTO constructor: (id, name, email, username, bio, profilePicture) - List manyLikes = List.of( - new BaseUserDTO(UUID.randomUUID(), "User 1", "user1@test.com", "user1", "bio", "pic1.jpg"), - new BaseUserDTO(UUID.randomUUID(), "User 2", "user2@test.com", "user2", "bio", "pic2.jpg"), - new BaseUserDTO(UUID.randomUUID(), "User 3", "user3@test.com", "user3", "bio", "pic3.jpg") - ); - - when(chatMessageService.getChatMessageLikes(chatMessageId)).thenReturn(manyLikes); - - mockMvc.perform(get("/api/v1/chat-messages/{chatMessageId}/likes", chatMessageId)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(3)); - - verify(chatMessageService, times(1)).getChatMessageLikes(chatMessageId); - } - - @Test - void createChatMessageLike_ShouldHandleDuplicateLike_WhenUserAlreadyLiked() throws Exception { - when(chatMessageService.createChatMessageLike(chatMessageId, userId)) - .thenThrow(new RuntimeException("Duplicate like")); - - mockMvc.perform(post("/api/v1/chat-messages/{chatMessageId}/likes/{userId}", chatMessageId, userId)) - .andExpect(status().isInternalServerError()); - - verify(logger, times(1)).error(contains("Error creating chat message like")); - } - - @Test - void createChatMessage_ShouldHandleConcurrentMessages_WhenMultipleUsersPost() throws Exception { - when(chatMessageService.createChatMessage(any(CreateChatMessageDTO.class))) - .thenReturn(fullActivityChatMessageDTO); - - // Simulate concurrent message creation - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createChatMessageDTO))) - .andExpect(status().isCreated()); - - mockMvc.perform(post("/api/v1/chat-messages") - .contentType(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(createChatMessageDTO))) - .andExpect(status().isCreated()); - - verify(chatMessageService, times(2)).createChatMessage(any(CreateChatMessageDTO.class)); - } -} - diff --git a/src/test/java/com/danielagapov/spawn/IntegrationTests/ActivityTypeIntegrationTests.java b/src/test/java/com/danielagapov/spawn/IntegrationTests/ActivityTypeIntegrationTests.java deleted file mode 100644 index c0ad187ae..000000000 --- a/src/test/java/com/danielagapov/spawn/IntegrationTests/ActivityTypeIntegrationTests.java +++ /dev/null @@ -1,474 +0,0 @@ -package com.danielagapov.spawn.IntegrationTests; - -import com.danielagapov.spawn.activity.api.ActivityTypeController; -import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.activity.api.dto.BatchActivityTypeUpdateDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.exceptions.ActivityTypeValidationException; -import com.danielagapov.spawn.activity.internal.domain.ActivityType; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityTypeRepository; -import com.danielagapov.spawn.activity.internal.services.ActivityTypeService; -import com.danielagapov.spawn.user.internal.services.IUserService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.http.HttpStatus; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter; -import org.springframework.test.web.servlet.MockMvc; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; - -import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; - -/** - * Integration tests for Activity Type management workflow - * These tests simulate complete front-end user journeys and workflows - */ -@ExtendWith(MockitoExtension.class) -class ActivityTypeIntegrationTests { - - @Mock - private IActivityTypeRepository activityTypeRepository; - - @Mock - private IUserService userService; - - @Mock - private ILogger logger; - - private ActivityTypeService activityTypeService; - private ActivityTypeController activityTypeController; - private MockMvc mockMvc; - private ObjectMapper objectMapper; - - private UUID userId; - private User testUser; - private List defaultActivityTypes; - - @BeforeEach - void setUp() { - // Initialize service and controller with real implementations - activityTypeService = new ActivityTypeService(activityTypeRepository, logger, userService); - activityTypeController = new ActivityTypeController(activityTypeService, logger); - - objectMapper = new ObjectMapper(); - // Configure MockMvc with proper JSON message converter - mockMvc = MockMvcBuilders.standaloneSetup(activityTypeController) - .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) - .build(); - - userId = UUID.randomUUID(); - testUser = createTestUser(); - defaultActivityTypes = createDefaultActivityTypes(); - } - - private User createTestUser() { - User user = new User(); - user.setId(userId); - user.setUsername("testuser"); - user.setName("Test User"); - user.setEmail("test@example.com"); - return user; - } - - private List createDefaultActivityTypes() { - List types = new ArrayList<>(); - - ActivityType chill = new ActivityType(); - chill.setId(UUID.randomUUID()); - chill.setTitle("Chill"); - chill.setIcon("🛋️"); - chill.setCreator(testUser); - chill.setOrderNum(1); - chill.setIsPinned(false); - types.add(chill); - - ActivityType food = new ActivityType(); - food.setId(UUID.randomUUID()); - food.setTitle("Food"); - food.setIcon("🍽️"); - food.setCreator(testUser); - food.setOrderNum(2); - food.setIsPinned(true); - types.add(food); - - ActivityType active = new ActivityType(); - active.setId(UUID.randomUUID()); - active.setTitle("Active"); - active.setIcon("🏃"); - active.setCreator(testUser); - active.setOrderNum(3); - active.setIsPinned(false); - types.add(active); - - return types; - } - - // MARK: - Complete Front-End User Journey Tests - - @Test - void fullUserJourney_ShouldWorkEndToEnd_WhenUserManagesActivityTypes() throws Exception { - // PHASE 1: Initial load - User opens activity type management page - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(defaultActivityTypes); - - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", userId) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(3)) - .andExpect(jsonPath("$[0].title").value("Chill")) - .andExpect(jsonPath("$[1].title").value("Food")) - .andExpect(jsonPath("$[2].title").value("Active")); - - // PHASE 2: User makes changes locally in UI then saves all at once - // User actions: - // 1. Pins "Chill" - // 2. Unpins "Food" - // 3. Creates new "Study" activity type - // 4. Deletes "Active" - // 5. Reorders everything - - UUID newStudyId = UUID.randomUUID(); - List batchUpdates = Arrays.asList( - new ActivityTypeDTO(defaultActivityTypes.get(0).getId(), "Chill", List.of(), "🛋️", 1, userId, true), // Pinned - new ActivityTypeDTO(defaultActivityTypes.get(1).getId(), "Food", List.of(), "🍽️", 2, userId, false), // Unpinned - new ActivityTypeDTO(newStudyId, "Study", List.of(), "📚", 3, userId, false) // New - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - batchUpdates, - Arrays.asList(defaultActivityTypes.get(2).getId()) // Delete Active - ); - - // Mock the service layer responses - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); // Current pinned count - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); // Current total count - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Mock the final result after all changes - List finalResult = Arrays.asList( - createActivityTypeFromDTO(batchUpdates.get(0)), - createActivityTypeFromDTO(batchUpdates.get(1)), - createActivityTypeFromDTO(batchUpdates.get(2)) - ); - - // Set up repository mock to return different results for different calls - // Set up repository mock to return different results for different calls - AtomicInteger callCount = new AtomicInteger(0); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenAnswer(invocation -> { - int call = callCount.incrementAndGet(); - if (call <= 2) { - return defaultActivityTypes; // First 2 calls return original (validation + assignOrderNumbers) - } else { - return finalResult; // Call #3 and subsequent calls return updated (final result) - } - }); - - when(activityTypeRepository.saveAll(anyList())).thenReturn(finalResult); - - // PHASE 3: User submits all changes - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(batchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(3)) - .andExpect(jsonPath("$[0].title").value("Chill")) - .andExpect(jsonPath("$[0].isPinned").value(true)) - .andExpect(jsonPath("$[1].title").value("Food")) - .andExpect(jsonPath("$[1].isPinned").value(false)) - .andExpect(jsonPath("$[2].title").value("Study")); - - // Verify the complete workflow - verify(activityTypeRepository, times(4)).findActivityTypesByCreatorId(userId); // Initial GET + validation + assignOrderNumbers + final result - verify(activityTypeRepository, times(1)).deleteAllById(anyList()); // Delete operation - verify(activityTypeRepository, times(1)).saveAll(anyList()); // Save operation - } - - @Test - void newUserWorkflow_ShouldCreateDefaultTypes_WhenUserHasNoActivityTypes() { - // Simulate new user workflow - they have no activity types initially - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of()); - - // Call service method directly as would happen during user registration - assertDoesNotThrow(() -> activityTypeService.initializeDefaultActivityTypesForUser(testUser)); - - // Verify 4 default activity types are created - verify(activityTypeRepository, times(1)).saveAll(argThat(list -> - ((List) list).size() == 4 - )); - } - - @Test - void powerUserWorkflow_ShouldHandleManyActivityTypes_WhenUserHasLargeCollection() throws Exception { - // Simulate power user with many activity types (20+) - List manyActivityTypes = new ArrayList<>(); - List manyUpdates = new ArrayList<>(); - - for (int i = 1; i <= 25; i++) { - ActivityType type = new ActivityType(); - type.setId(UUID.randomUUID()); - type.setTitle("Type " + (i - 1)); - type.setIcon("🎯"); - type.setCreator(testUser); - type.setOrderNum(i); - type.setIsPinned(i <= 4); // First 4 are pinned - manyActivityTypes.add(type); - - // Create corresponding DTO - ActivityTypeDTO dto = new ActivityTypeDTO( - type.getId(), type.getTitle(), List.of(), type.getIcon(), - i, userId, type.getIsPinned() - ); - manyUpdates.add(dto); - } - - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(manyActivityTypes); - - // Test initial load - mockMvc.perform(get("/api/v1/users/{userId}/activity-types", userId) - .accept(MediaType.APPLICATION_JSON)) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(25)); - - // Test bulk update - BatchActivityTypeUpdateDTO largeBatchDTO = new BatchActivityTypeUpdateDTO(manyUpdates, List.of()); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(3L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(25L); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(manyActivityTypes); - - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(largeBatchDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(25)); - } - - @Test - void errorRecoveryWorkflow_ShouldHandleGracefully_WhenValidationFails() throws Exception { - // Simulate user attempting invalid operation (too many pins) - List invalidUpdates = Arrays.asList( - new ActivityTypeDTO(UUID.randomUUID(), "Type1", List.of(), "🎯", 1, userId, true), - new ActivityTypeDTO(UUID.randomUUID(), "Type2", List.of(), "🎯", 2, userId, true), - new ActivityTypeDTO(UUID.randomUUID(), "Type3", List.of(), "🎯", 3, userId, true), - new ActivityTypeDTO(UUID.randomUUID(), "Type4", List.of(), "🎯", 4, userId, true) // 4th pin - invalid - ); - - BatchActivityTypeUpdateDTO invalidBatchDTO = new BatchActivityTypeUpdateDTO(invalidUpdates, List.of()); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(0L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of()); - - // Should return internal server error due to validation failure - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(invalidBatchDTO))) - .andExpect(status().isInternalServerError()); - - // Verify no changes were saved - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void concurrentUserWorkflow_ShouldHandleLocking_WhenMultipleUpdates() { - // Simulate concurrent updates to same user's activity types - ActivityTypeDTO update1 = new ActivityTypeDTO( - defaultActivityTypes.get(0).getId(), "Chill Updated 1", List.of(), "🛋️", 1, userId, false - ); - ActivityTypeDTO update2 = new ActivityTypeDTO( - defaultActivityTypes.get(0).getId(), "Chill Updated 2", List.of(), "🛋️", 1, userId, true - ); - - BatchActivityTypeUpdateDTO batch1 = new BatchActivityTypeUpdateDTO(Arrays.asList(update1), List.of()); - BatchActivityTypeUpdateDTO batch2 = new BatchActivityTypeUpdateDTO(Arrays.asList(update2), List.of()); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(defaultActivityTypes.subList(0, 1)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(defaultActivityTypes.subList(0, 1)); - - // Both updates should complete (synchronization handles concurrency) - ResponseEntity> result1 = activityTypeController.updateActivityTypes(userId, batch1); - ResponseEntity> result2 = activityTypeController.updateActivityTypes(userId, batch2); - - assertEquals(HttpStatus.OK, result1.getStatusCode()); - assertEquals(HttpStatus.OK, result2.getStatusCode()); - verify(activityTypeRepository, times(2)).saveAll(anyList()); - } - - @Test - void mobileAppWorkflow_ShouldHandleQuickActions_WhenUserMakesRapidChanges() throws Exception { - // Simulate mobile app rapid pin/unpin actions - ActivityTypeDTO quickPin = new ActivityTypeDTO( - defaultActivityTypes.get(0).getId(), "Chill", List.of(), "🛋️", 1, userId, true - ); - ActivityTypeDTO quickUnpin = new ActivityTypeDTO( - defaultActivityTypes.get(0).getId(), "Chill", List.of(), "🛋️", 1, userId, false - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(defaultActivityTypes.subList(0, 1)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(defaultActivityTypes.subList(0, 1)); - - // Rapid pin - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new BatchActivityTypeUpdateDTO(Arrays.asList(quickPin), List.of())))) - .andExpect(status().isOk()); - - // Rapid unpin - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(new BatchActivityTypeUpdateDTO(Arrays.asList(quickUnpin), List.of())))) - .andExpect(status().isOk()); - - verify(activityTypeRepository, times(2)).saveAll(anyList()); - } - - @Test - void offlineToOnlineWorkflow_ShouldSyncCorrectly_WhenUserComesBackOnline() throws Exception { - // Simulate user making many changes offline, then syncing when back online - UUID study1Id = UUID.randomUUID(); - UUID study2Id = UUID.randomUUID(); - UUID study3Id = UUID.randomUUID(); - - List offlineChanges = Arrays.asList( - // Modified existing - new ActivityTypeDTO(defaultActivityTypes.get(0).getId(), "Chill & Relax", List.of(), "🛋️", 1, userId, true), - // Created while offline - new ActivityTypeDTO(study1Id, "Study Session", List.of(), "📚", 2, userId, false), - new ActivityTypeDTO(study2Id, "Deep Work", List.of(), "💻", 3, userId, false), - new ActivityTypeDTO(study3Id, "Reading", List.of(), "📖", 4, userId, false) - ); - - // Deleted while offline - List deletedOffline = Arrays.asList( - defaultActivityTypes.get(1).getId(), // Food - defaultActivityTypes.get(2).getId() // Active - ); - - BatchActivityTypeUpdateDTO offlineSyncDTO = new BatchActivityTypeUpdateDTO(offlineChanges, deletedOffline); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Create final result with 4 activity types - List offlineFinalResult = Arrays.asList( - createActivityTypeFromDTO(offlineChanges.get(0)), - createActivityTypeFromDTO(offlineChanges.get(1)), - createActivityTypeFromDTO(offlineChanges.get(2)), - createActivityTypeFromDTO(offlineChanges.get(3)) - ); - - // Set up repository mock to return different results for different calls - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(defaultActivityTypes) // First call during validation - .thenReturn(offlineFinalResult); // Second call for final result - - when(activityTypeRepository.saveAll(anyList())).thenReturn(offlineFinalResult); - - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(offlineSyncDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(4)); - - // Verify deletions and saves happened - verify(activityTypeRepository, times(1)).deleteAllById(deletedOffline); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void dataIntegrityWorkflow_ShouldMaintainConsistency_WhenComplexOperations() throws Exception { - // Test complex scenario that could break data integrity - // 1. Reorder all items - // 2. Change pin status - // 3. Delete some, create some - // 4. Ensure order numbers remain consistent - - UUID newType1Id = UUID.randomUUID(); - UUID newType2Id = UUID.randomUUID(); - - List complexChanges = Arrays.asList( - // Existing items reordered and pin status changed - new ActivityTypeDTO(defaultActivityTypes.get(1).getId(), "Food", List.of(), "🍽️", 1, userId, false), // Was pinned, now not - new ActivityTypeDTO(defaultActivityTypes.get(0).getId(), "Chill", List.of(), "🛋️", 2, userId, true), // Was not pinned, now pinned - // New items - new ActivityTypeDTO(newType1Id, "Work", List.of(), "💼", 3, userId, false), - new ActivityTypeDTO(newType2Id, "Travel", List.of(), "✈️", 4, userId, false) - ); - - List toDelete = Arrays.asList(defaultActivityTypes.get(2).getId()); // Delete Active - - BatchActivityTypeUpdateDTO complexDTO = new BatchActivityTypeUpdateDTO(complexChanges, toDelete); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Create final result with 4 activity types (2 existing updated + 2 new) - List complexFinalResult = Arrays.asList( - createActivityTypeFromDTO(complexChanges.get(0)), - createActivityTypeFromDTO(complexChanges.get(1)), - createActivityTypeFromDTO(complexChanges.get(2)), - createActivityTypeFromDTO(complexChanges.get(3)) - ); - - // Set up repository mock to return different results for different calls - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(defaultActivityTypes) // First call during validation - .thenReturn(complexFinalResult); // Second call for final result - - when(activityTypeRepository.saveAll(anyList())).thenReturn(complexFinalResult); - - mockMvc.perform(put("/api/v1/users/{userId}/activity-types", userId) - .contentType(MediaType.APPLICATION_JSON) - .accept(MediaType.APPLICATION_JSON) - .content(objectMapper.writeValueAsString(complexDTO))) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.length()").value(4)); - - // Verify operations happened in correct order - verify(activityTypeRepository, times(1)).deleteAllById(toDelete); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - // MARK: - Helper Methods - - private ActivityType createActivityTypeFromDTO(ActivityTypeDTO dto) { - ActivityType activityType = new ActivityType(); - activityType.setId(dto.getId()); - activityType.setTitle(dto.getTitle()); - activityType.setIcon(dto.getIcon()); - activityType.setOrderNum(dto.getOrderNum()); - activityType.setIsPinned(dto.getIsPinned()); - activityType.setCreator(testUser); - return activityType; - } -} \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/PerformanceTests/ActivityTypePerformanceTests.java b/src/test/java/com/danielagapov/spawn/PerformanceTests/ActivityTypePerformanceTests.java deleted file mode 100644 index 0765264bc..000000000 --- a/src/test/java/com/danielagapov/spawn/PerformanceTests/ActivityTypePerformanceTests.java +++ /dev/null @@ -1,462 +0,0 @@ -package com.danielagapov.spawn.PerformanceTests; - -import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.activity.api.dto.BatchActivityTypeUpdateDTO; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.activity.internal.domain.ActivityType; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityTypeRepository; -import com.danielagapov.spawn.activity.internal.services.ActivityTypeService; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.Timeout; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.util.StopWatch; - -import java.util.*; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.stream.IntStream; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; - -/** - * Performance tests for Activity Type management under high load conditions - * These tests verify the system can handle large datasets and concurrent operations - * as might be experienced in production with heavy front-end usage - */ -@ExtendWith(MockitoExtension.class) -class ActivityTypePerformanceTests { - - @Mock - private IActivityTypeRepository activityTypeRepository; - - @Mock - private IUserService userService; - - @Mock - private ILogger logger; - - private ActivityTypeService activityTypeService; - private UUID userId; - private User testUser; - - @BeforeEach - void setUp() { - activityTypeService = new ActivityTypeService(activityTypeRepository, logger, userService); - userId = UUID.randomUUID(); - testUser = createTestUser(); - } - - private User createTestUser() { - User user = new User(); - user.setId(userId); - user.setUsername("perftest_user"); - user.setName("Performance Test User"); - user.setEmail("perftest@example.com"); - return user; - } - - // MARK: - Large Dataset Performance Tests - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldCompleteWithinTimeLimit_WhenProcessing100ActivityTypes() { - // Arrange - Large batch of activity types (100 items) - List largeUpdateBatch = createLargeActivityTypeBatch(100); - BatchActivityTypeUpdateDTO largeBatchDTO = new BatchActivityTypeUpdateDTO(largeUpdateBatch, List.of()); - - // Mock repository responses for large dataset - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(100L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(100)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(100)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - List result = activityTypeService.updateActivityTypes(userId, largeBatchDTO); - - stopWatch.stop(); - - // Assert - assertNotNull(result); - assertTrue(stopWatch.getTotalTimeMillis() < 10000, "Operation took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed 100 activity types in: " + stopWatch.getTotalTimeMillis() + "ms"); - } - - @Test - @Timeout(value = 30, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldHandleMaximumLoad_WhenProcessing500ActivityTypes() { - // Arrange - Maximum realistic load (500 items - extreme power user scenario) - List maximumBatch = createLargeActivityTypeBatch(500); - BatchActivityTypeUpdateDTO maxBatchDTO = new BatchActivityTypeUpdateDTO(maximumBatch, List.of()); - - // Mock for maximum load - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(500L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(500)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(500)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - List result = activityTypeService.updateActivityTypes(userId, maxBatchDTO); - - stopWatch.stop(); - - // Assert - assertNotNull(result); - assertTrue(stopWatch.getTotalTimeMillis() < 30000, "Maximum load operation took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed 500 activity types in: " + stopWatch.getTotalTimeMillis() + "ms"); - } - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void fetchActivityTypes_ShouldBeOptimized_WhenRetrievingLargeCollection() { - // Arrange - Large collection retrieval (simulating user with many activity types) - List largeCollection = createLargeActivityTypeEntityBatch(200); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(largeCollection); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - List result = activityTypeService.getActivityTypesByUserId(userId); - - stopWatch.stop(); - - // Assert - assertNotNull(result); - assertEquals(200, result.size()); - assertTrue(stopWatch.getTotalTimeMillis() < 5000, "Fetch operation took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Fetched 200 activity types in: " + stopWatch.getTotalTimeMillis() + "ms"); - } - - // MARK: - Concurrent Access Performance Tests - - @Test - @Timeout(value = 15, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldHandleConcurrentRequests_WhenMultipleUsersUpdateSimultaneously() throws InterruptedException, ExecutionException { - // Arrange - Simulate 10 concurrent users updating their activity types - int numberOfConcurrentUsers = 10; - List>> futures = new ArrayList<>(); - - // Setup mocks for concurrent access - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(any(UUID.class))).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(any(UUID.class))).thenReturn(5L); - when(activityTypeRepository.findActivityTypesByCreatorId(any(UUID.class))) - .thenReturn(createLargeActivityTypeEntityBatch(5)); - when(userService.getUserEntityById(any(UUID.class))).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(5)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - Create concurrent update requests - for (int i = 0; i < numberOfConcurrentUsers; i++) { - UUID concurrentUserId = UUID.randomUUID(); - List userBatch = createLargeActivityTypeBatch(20); - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO(userBatch, List.of()); - - CompletableFuture> future = CompletableFuture.supplyAsync(() -> - activityTypeService.updateActivityTypes(concurrentUserId, batchDTO) - ); - futures.add(future); - } - - // Wait for all to complete - CompletableFuture allFutures = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); - allFutures.get(); - - stopWatch.stop(); - - // Assert - for (CompletableFuture> future : futures) { - assertNotNull(future.get()); - } - assertTrue(stopWatch.getTotalTimeMillis() < 15000, "Concurrent operations took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed " + numberOfConcurrentUsers + " concurrent updates in: " + stopWatch.getTotalTimeMillis() + "ms"); - - // Verify all operations completed - verify(activityTypeRepository, times(numberOfConcurrentUsers)).saveAll(anyList()); - } - - @Test - @Timeout(value = 10, unit = TimeUnit.SECONDS) - void rapidFireUpdates_ShouldMaintainPerformance_WhenUserMakesQuickSuccessiveChanges() { - // Arrange - Simulate user making rapid successive updates (like quickly toggling pins) - int numberOfRapidUpdates = 50; - List baseActivityTypes = createLargeActivityTypeBatch(5); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(5L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(5)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(5)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - Perform rapid successive updates - for (int i = 0; i < numberOfRapidUpdates; i++) { - // Toggle pin status for first activity type - ActivityTypeDTO toggledType = new ActivityTypeDTO( - baseActivityTypes.get(0).getId(), - baseActivityTypes.get(0).getTitle(), - List.of(), - baseActivityTypes.get(0).getIcon(), - 0, - userId, - i % 2 == 0 // Alternate pin status - ); - - BatchActivityTypeUpdateDTO rapidBatchDTO = new BatchActivityTypeUpdateDTO(Arrays.asList(toggledType), List.of()); - List result = activityTypeService.updateActivityTypes(userId, rapidBatchDTO); - assertNotNull(result); - } - - stopWatch.stop(); - - // Assert - assertTrue(stopWatch.getTotalTimeMillis() < 10000, "Rapid fire updates took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed " + numberOfRapidUpdates + " rapid updates in: " + stopWatch.getTotalTimeMillis() + "ms"); - verify(activityTypeRepository, times(numberOfRapidUpdates)).saveAll(anyList()); - } - - // MARK: - Memory Performance Tests - - @Test - @Timeout(value = 20, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldHandleMemoryEfficiently_WhenProcessingMassiveDeletion() { - // Arrange - Test memory efficiency with massive deletion (delete 1000 items) - List massiveDeletionList = new ArrayList<>(); - for (int i = 0; i < 1000; i++) { - massiveDeletionList.add(UUID.randomUUID()); - } - - BatchActivityTypeUpdateDTO massiveDeletionDTO = new BatchActivityTypeUpdateDTO(List.of(), massiveDeletionList); - - // Mock for massive deletion scenario - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(50)); // Remaining after deletion - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - List result = activityTypeService.updateActivityTypes(userId, massiveDeletionDTO); - - stopWatch.stop(); - - // Assert - assertNotNull(result); - assertTrue(stopWatch.getTotalTimeMillis() < 20000, "Massive deletion took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed deletion of 1000 items in: " + stopWatch.getTotalTimeMillis() + "ms"); - verify(activityTypeRepository, times(1)).deleteAllById(massiveDeletionList); - } - - @Test - @Timeout(value = 15, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldOptimizeForMixedOperations_WhenCombiningMultipleOperationTypes() { - // Arrange - Mixed operations: create 100, update 100, delete 100 - List creationBatch = createLargeActivityTypeBatch(100); - List updateBatch = createLargeActivityTypeBatch(100); - List deletionBatch = createLargeDeletionBatch(100); - - List mixedBatch = new ArrayList<>(); - mixedBatch.addAll(creationBatch); - mixedBatch.addAll(updateBatch); - - BatchActivityTypeUpdateDTO mixedOperationsDTO = new BatchActivityTypeUpdateDTO(mixedBatch, deletionBatch); - - // Mock for mixed operations - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(200L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(200)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(200)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - List result = activityTypeService.updateActivityTypes(userId, mixedOperationsDTO); - - stopWatch.stop(); - - // Assert - assertNotNull(result); - assertTrue(stopWatch.getTotalTimeMillis() < 15000, "Mixed operations took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Processed mixed operations (200 updates + 100 deletions) in: " + stopWatch.getTotalTimeMillis() + "ms"); - } - - // MARK: - Scalability Tests - - @Test - @Timeout(value = 5, unit = TimeUnit.SECONDS) - void initializeDefaultActivityTypes_ShouldScaleLinearely_WhenCalledForManyUsers() { - // Arrange - Test initialization performance for multiple users - int numberOfUsers = 100; - List users = createMultipleUsers(numberOfUsers); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - for (User user : users) { - activityTypeService.initializeDefaultActivityTypesForUser(user); - } - - stopWatch.stop(); - - // Assert - assertTrue(stopWatch.getTotalTimeMillis() < 5000, "Bulk initialization took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Initialized default types for " + numberOfUsers + " users in: " + stopWatch.getTotalTimeMillis() + "ms"); - verify(activityTypeRepository, times(numberOfUsers)).saveAll(anyList()); - } - - @Test - @Timeout(value = 8, unit = TimeUnit.SECONDS) - void getActivityTypesByUserId_ShouldCacheEffectively_WhenRepeatedlyAccessed() { - // Arrange - Test cache performance with repeated access - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(50)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - Multiple repeated calls (would hit cache in real scenario) - for (int i = 0; i < 100; i++) { - List result = activityTypeService.getActivityTypesByUserId(userId); - assertNotNull(result); - assertEquals(50, result.size()); - } - - stopWatch.stop(); - - // Assert - assertTrue(stopWatch.getTotalTimeMillis() < 8000, "Repeated access took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("100 repeated fetches completed in: " + stopWatch.getTotalTimeMillis() + "ms"); - // Note: In real scenario with caching, repository would only be called once - } - - // MARK: - Stress Tests - - @Test - @Timeout(value = 60, unit = TimeUnit.SECONDS) - void batchUpdate_ShouldSurviveStressTest_WhenUnderExtremLoad() { - // Arrange - Extreme stress test: 1000 activity types across multiple operations - List extremeBatch = createLargeActivityTypeBatch(800); - List extremeDeletion = createLargeDeletionBatch(200); - BatchActivityTypeUpdateDTO extremeStressDTO = new BatchActivityTypeUpdateDTO(extremeBatch, extremeDeletion); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1000L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(createLargeActivityTypeEntityBatch(1000)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(createLargeActivityTypeEntityBatch(800)); - - StopWatch stopWatch = new StopWatch(); - stopWatch.start(); - - // Act - assertDoesNotThrow(() -> { - List result = activityTypeService.updateActivityTypes(userId, extremeStressDTO); - assertNotNull(result); - }); - - stopWatch.stop(); - - // Assert - assertTrue(stopWatch.getTotalTimeMillis() < 60000, "Stress test took too long: " + stopWatch.getTotalTimeMillis() + "ms"); - System.out.println("Stress test (800 updates + 200 deletions) completed in: " + stopWatch.getTotalTimeMillis() + "ms"); - } - - // MARK: - Helper Methods - - private List createLargeActivityTypeBatch(int count) { - return IntStream.range(0, count) - .mapToObj(i -> new ActivityTypeDTO( - UUID.randomUUID(), - "Activity Type " + i, - List.of(), - "🎯", - i, - userId, - false // No pinned activity types in performance tests to avoid validation issues - )) - .toList(); - } - - /** - * Creates a batch of activity types with a controlled number of pinned items (max 4) - * Use this when testing pinned functionality specifically - */ - private List createActivityTypeBatchWithPinnedLimit(int count, int pinnedCount) { - if (pinnedCount > 4) { - throw new IllegalArgumentException("Cannot create more than 4 pinned activity types"); - } - return IntStream.range(0, count) - .mapToObj(i -> new ActivityTypeDTO( - UUID.randomUUID(), - "Activity Type " + i, - List.of(), - "🎯", - i, - userId, - i < pinnedCount // First 'pinnedCount' items are pinned - )) - .toList(); - } - - private List createLargeActivityTypeEntityBatch(int count) { - return IntStream.range(0, count) - .mapToObj(i -> { - ActivityType activityType = new ActivityType(); - activityType.setId(UUID.randomUUID()); - activityType.setTitle("Activity Type " + i); - activityType.setIcon("🎯"); - activityType.setOrderNum(i); - activityType.setIsPinned(false); // No pinned activity types in performance tests - activityType.setCreator(testUser); - return activityType; - }) - .toList(); - } - - private List createLargeDeletionBatch(int count) { - return IntStream.range(0, count) - .mapToObj(i -> UUID.randomUUID()) - .toList(); - } - - private List createMultipleUsers(int count) { - return IntStream.range(0, count) - .mapToObj(i -> { - User user = new User(); - user.setId(UUID.randomUUID()); - user.setUsername("user" + i); - user.setName("User " + i); - user.setEmail("user" + i + "@test.com"); - return user; - }) - .toList(); - } -} \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/RepositoryTests/ActivityRepositoryTests.java b/src/test/java/com/danielagapov/spawn/RepositoryTests/ActivityRepositoryTests.java deleted file mode 100644 index 3dd76ea2c..000000000 --- a/src/test/java/com/danielagapov/spawn/RepositoryTests/ActivityRepositoryTests.java +++ /dev/null @@ -1,385 +0,0 @@ -package com.danielagapov.spawn.RepositoryTests; - -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.Location; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityRepository; -import com.danielagapov.spawn.activity.internal.repositories.ILocationRepository; -import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.shared.util.UserStatus; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; -import org.springframework.test.context.ActiveProfiles; - -import java.time.OffsetDateTime; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static org.junit.jupiter.api.Assertions.*; - -/** - * Integration tests for ActivityRepository - * Tests database operations for Activity entities - */ -@DataJpaTest -@ActiveProfiles("test") -class ActivityRepositoryTests { - - @Autowired - private IActivityRepository activityRepository; - - @Autowired - private IUserRepository userRepository; - - @Autowired - private ILocationRepository locationRepository; - - private User testUser; - private Location testLocation; - private Activity testActivity; - - @BeforeEach - void setUp() { - // Clean up before each test - activityRepository.deleteAll(); - userRepository.deleteAll(); - locationRepository.deleteAll(); - - // Create test user - testUser = new User(); - testUser.setUsername("testuser"); - testUser.setEmail("test@example.com"); - testUser.setName("Test User"); - testUser.setProfilePictureUrlString("pic.jpg"); - testUser.setStatus(UserStatus.ACTIVE); - testUser = userRepository.save(testUser); - - // Create test location - testLocation = new Location(); - testLocation.setName("Test Location"); - testLocation.setLatitude(40.7128); - testLocation.setLongitude(-74.0060); - testLocation = locationRepository.save(testLocation); - - // Create test activity - testActivity = new Activity(); - testActivity.setTitle("Test Activity"); - testActivity.setStartTime(OffsetDateTime.now().plusDays(1)); - testActivity.setEndTime(OffsetDateTime.now().plusDays(1).plusHours(2)); - testActivity.setLocation(testLocation); - testActivity.setNote("Test note"); - testActivity.setCreator(testUser); - testActivity.setIcon("🎉"); - } - - // MARK: - Basic CRUD Tests - - @Test - void save_ShouldPersistActivity_WhenValidActivity() { - Activity saved = activityRepository.save(testActivity); - - assertNotNull(saved); - assertNotNull(saved.getId()); - assertEquals("Test Activity", saved.getTitle()); - assertEquals(testUser.getId(), saved.getCreator().getId()); - assertEquals(testLocation.getId(), saved.getLocation().getId()); - } - - @Test - void findById_ShouldReturnActivity_WhenActivityExists() { - Activity saved = activityRepository.save(testActivity); - - Optional found = activityRepository.findById(saved.getId()); - - assertTrue(found.isPresent()); - assertEquals(saved.getId(), found.get().getId()); - assertEquals("Test Activity", found.get().getTitle()); - } - - @Test - void findById_ShouldReturnEmpty_WhenActivityDoesNotExist() { - Optional found = activityRepository.findById(UUID.randomUUID()); - - assertFalse(found.isPresent()); - } - - @Test - void findAll_ShouldReturnAllActivities_WhenActivitiesExist() { - activityRepository.save(testActivity); - - Activity secondActivity = new Activity(); - secondActivity.setTitle("Second Activity"); - secondActivity.setStartTime(OffsetDateTime.now().plusDays(2)); - secondActivity.setEndTime(OffsetDateTime.now().plusDays(2).plusHours(2)); - secondActivity.setLocation(testLocation); - secondActivity.setCreator(testUser); - secondActivity.setIcon("🍽️"); - activityRepository.save(secondActivity); - - List all = activityRepository.findAll(); - - assertEquals(2, all.size()); - } - - @Test - void deleteById_ShouldRemoveActivity_WhenActivityExists() { - Activity saved = activityRepository.save(testActivity); - UUID activityId = saved.getId(); - - activityRepository.deleteById(activityId); - - Optional found = activityRepository.findById(activityId); - assertFalse(found.isPresent()); - } - - @Test - void existsById_ShouldReturnTrue_WhenActivityExists() { - Activity saved = activityRepository.save(testActivity); - - boolean exists = activityRepository.existsById(saved.getId()); - - assertTrue(exists); - } - - @Test - void existsById_ShouldReturnFalse_WhenActivityDoesNotExist() { - boolean exists = activityRepository.existsById(UUID.randomUUID()); - - assertFalse(exists); - } - - // MARK: - Custom Query Tests - - @Test - void findByCreatorId_ShouldReturnActivities_WhenUserHasActivities() { - activityRepository.save(testActivity); - - Activity secondActivity = new Activity(); - secondActivity.setTitle("Second Activity"); - secondActivity.setStartTime(OffsetDateTime.now().plusDays(2)); - secondActivity.setEndTime(OffsetDateTime.now().plusDays(2).plusHours(2)); - secondActivity.setLocation(testLocation); - secondActivity.setCreator(testUser); - secondActivity.setIcon("⚽"); - activityRepository.save(secondActivity); - - List userActivities = activityRepository.findByCreatorId(testUser.getId()); - - assertEquals(2, userActivities.size()); - assertTrue(userActivities.stream().allMatch(a -> a.getCreator().getId().equals(testUser.getId()))); - } - - @Test - void findByCreatorId_ShouldReturnEmpty_WhenUserHasNoActivities() { - User anotherUser = new User(); - anotherUser.setUsername("anotheruser"); - anotherUser.setEmail("another@example.com"); - anotherUser.setName("Another User"); - anotherUser.setStatus(UserStatus.ACTIVE); - anotherUser = userRepository.save(anotherUser); - - List userActivities = activityRepository.findByCreatorId(anotherUser.getId()); - - assertTrue(userActivities.isEmpty()); - } - - // MARK: - Update Tests - - @Test - void update_ShouldModifyActivity_WhenActivityExists() { - Activity saved = activityRepository.save(testActivity); - - saved.setTitle("Updated Title"); - saved.setNote("Updated note"); - Activity updated = activityRepository.save(saved); - - assertEquals("Updated Title", updated.getTitle()); - assertEquals("Updated note", updated.getNote()); - assertEquals(saved.getId(), updated.getId()); - } - - @Test - void update_ShouldModifyStartTime_WhenTimeChanged() { - Activity saved = activityRepository.save(testActivity); - OffsetDateTime newStartTime = OffsetDateTime.now().plusDays(3); - - saved.setStartTime(newStartTime); - Activity updated = activityRepository.save(saved); - - assertEquals(newStartTime, updated.getStartTime()); - } - - @Test - void update_ShouldModifyParticipantLimit_WhenLimitChanged() { - testActivity.setParticipantLimit(10); - Activity saved = activityRepository.save(testActivity); - - saved.setParticipantLimit(20); - Activity updated = activityRepository.save(saved); - - assertEquals(20, updated.getParticipantLimit()); - } - - // MARK: - Relationship Tests - - @Test - void save_ShouldMaintainCreatorRelationship_WhenActivitySaved() { - Activity saved = activityRepository.save(testActivity); - - Activity found = activityRepository.findById(saved.getId()).orElseThrow(); - - assertNotNull(found.getCreator()); - assertEquals(testUser.getId(), found.getCreator().getId()); - assertEquals(testUser.getUsername(), found.getCreator().getUsername()); - } - - @Test - void save_ShouldMaintainLocationRelationship_WhenActivitySaved() { - Activity saved = activityRepository.save(testActivity); - - Activity found = activityRepository.findById(saved.getId()).orElseThrow(); - - assertNotNull(found.getLocation()); - assertEquals(testLocation.getId(), found.getLocation().getId()); - assertEquals(testLocation.getName(), found.getLocation().getName()); - } - - @Test - void delete_ShouldNotDeleteCreator_WhenActivityDeleted() { - Activity saved = activityRepository.save(testActivity); - UUID creatorId = testUser.getId(); - - activityRepository.deleteById(saved.getId()); - - Optional creator = userRepository.findById(creatorId); - assertTrue(creator.isPresent()); - } - - @Test - void delete_ShouldDeleteLocation_WhenActivityDeleted() { - // Note: Activity has CascadeType.REMOVE on location, so location is deleted with activity - Activity saved = activityRepository.save(testActivity); - UUID locationId = testLocation.getId(); - - activityRepository.deleteById(saved.getId()); - - Optional location = locationRepository.findById(locationId); - assertFalse(location.isPresent()); - } - - // MARK: - Edge Case Tests - - @Test - void save_ShouldHandleNullNote_WhenNoteNotProvided() { - testActivity.setNote(null); - - Activity saved = activityRepository.save(testActivity); - - assertNotNull(saved); - assertNull(saved.getNote()); - } - - @Test - void save_ShouldHandleNullParticipantLimit_WhenLimitNotSet() { - testActivity.setParticipantLimit(null); - - Activity saved = activityRepository.save(testActivity); - - assertNotNull(saved); - assertNull(saved.getParticipantLimit()); - } - - @Test - void save_ShouldHandleLongTitle_WhenTitleIsVeryLong() { - String longTitle = "A".repeat(255); - testActivity.setTitle(longTitle); - - Activity saved = activityRepository.save(testActivity); - - assertEquals(longTitle, saved.getTitle()); - } - - @Test - void findAll_ShouldReturnEmpty_WhenNoActivities() { - List all = activityRepository.findAll(); - - assertTrue(all.isEmpty()); - } - - @Test - void save_ShouldHandleMultipleActivitiesSameTime_WhenTimesOverlap() { - OffsetDateTime sameTime = OffsetDateTime.now().plusDays(1); - - testActivity.setStartTime(sameTime); - testActivity.setEndTime(sameTime.plusHours(2)); - Activity first = activityRepository.save(testActivity); - - Activity second = new Activity(); - second.setTitle("Overlapping Activity"); - second.setStartTime(sameTime); - second.setEndTime(sameTime.plusHours(2)); - second.setLocation(testLocation); - second.setCreator(testUser); - second.setIcon("🎊"); - Activity secondSaved = activityRepository.save(second); - - assertNotNull(first.getId()); - assertNotNull(secondSaved.getId()); - assertNotEquals(first.getId(), secondSaved.getId()); - } - - @Test - void findByCreatorId_ShouldReturnActivitiesInOrder_WhenMultipleActivities() { - // Create activities with different times - Activity first = new Activity(); - first.setTitle("First Activity"); - first.setStartTime(OffsetDateTime.now().plusDays(1)); - first.setEndTime(OffsetDateTime.now().plusDays(1).plusHours(2)); - first.setLocation(testLocation); - first.setCreator(testUser); - first.setIcon("1️⃣"); - activityRepository.save(first); - - Activity second = new Activity(); - second.setTitle("Second Activity"); - second.setStartTime(OffsetDateTime.now().plusDays(2)); - second.setEndTime(OffsetDateTime.now().plusDays(2).plusHours(2)); - second.setLocation(testLocation); - second.setCreator(testUser); - second.setIcon("2️⃣"); - activityRepository.save(second); - - Activity third = new Activity(); - third.setTitle("Third Activity"); - third.setStartTime(OffsetDateTime.now().plusDays(3)); - third.setEndTime(OffsetDateTime.now().plusDays(3).plusHours(2)); - third.setLocation(testLocation); - third.setCreator(testUser); - third.setIcon("3️⃣"); - activityRepository.save(third); - - List activities = activityRepository.findByCreatorId(testUser.getId()); - - assertEquals(3, activities.size()); - } - - @Test - void save_ShouldGenerateUniqueIds_WhenMultipleActivities() { - Activity first = activityRepository.save(testActivity); - - Activity second = new Activity(); - second.setTitle("Second Activity"); - second.setStartTime(OffsetDateTime.now().plusDays(2)); - second.setEndTime(OffsetDateTime.now().plusDays(2).plusHours(2)); - second.setLocation(testLocation); - second.setCreator(testUser); - second.setIcon("🎊"); - Activity secondSaved = activityRepository.save(second); - - assertNotEquals(first.getId(), secondSaved.getId()); - } -} - diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityExpirationServiceTimezoneTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityExpirationServiceTimezoneTests.java deleted file mode 100644 index 654954561..000000000 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityExpirationServiceTimezoneTests.java +++ /dev/null @@ -1,101 +0,0 @@ -package com.danielagapov.spawn.ServiceTests; - -import com.danielagapov.spawn.activity.internal.services.ActivityExpirationService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; - -import java.time.Instant; -import java.time.OffsetDateTime; -import java.time.ZoneOffset; -import java.time.temporal.ChronoUnit; - -import static org.junit.jupiter.api.Assertions.*; - -public class ActivityExpirationServiceTimezoneTests { - - private ActivityExpirationService expirationService; - - @BeforeEach - void setUp() { - expirationService = new ActivityExpirationService(); - } - - @Test - void testActivityExpirationWithTimezone_NewYorkTimezone() { - // Create an activity that was created at 2PM EST (7PM UTC) yesterday - Instant createdAt = Instant.now().minus(1, ChronoUnit.DAYS).plus(7, ChronoUnit.HOURS); - String clientTimezone = "America/New_York"; - - // Activity without end time should expire at midnight in New York timezone - // If it's currently after midnight New York time (5AM UTC), it should be expired - boolean isExpired = expirationService.isActivityExpired(null, null, createdAt, clientTimezone); - - // The result depends on current time, but we can verify the method doesn't throw exceptions - assertNotNull(isExpired); - } - - @Test - void testActivityExpirationWithTimezone_LondonTimezone() { - // Create an activity that was created at 2PM GMT (2PM UTC) yesterday - Instant createdAt = Instant.now().minus(1, ChronoUnit.DAYS).plus(2, ChronoUnit.HOURS); - String clientTimezone = "Europe/London"; - - // Activity without end time should expire at midnight in London timezone - boolean isExpired = expirationService.isActivityExpired(null, null, createdAt, clientTimezone); - - // The result depends on current time, but we can verify the method doesn't throw exceptions - assertNotNull(isExpired); - } - - @Test - void testActivityExpirationWithTimezone_InvalidTimezone() { - // Create an activity with an invalid timezone - Instant createdAt = Instant.now().minus(1, ChronoUnit.DAYS); - String clientTimezone = "Invalid/Timezone"; - - // Should fall back to UTC behavior when timezone is invalid - boolean isExpired = expirationService.isActivityExpired(null, null, createdAt, clientTimezone); - - // Should not throw exception and should fall back to UTC logic - assertNotNull(isExpired); - } - - @Test - void testActivityExpirationWithExplicitEndTime() { - // Activity with explicit end time should ignore timezone - Instant createdAt = Instant.now().minus(1, ChronoUnit.DAYS); - OffsetDateTime endTime = OffsetDateTime.now(ZoneOffset.UTC).minus(1, ChronoUnit.HOURS); - String clientTimezone = "America/New_York"; - - boolean isExpired = expirationService.isActivityExpired(null, endTime, createdAt, clientTimezone); - - // Should be expired since end time was 1 hour ago - assertTrue(isExpired); - } - - @Test - void testActivityExpirationWithNullTimezone() { - // Activity with null timezone should use UTC behavior - Instant createdAt = Instant.now().minus(2, ChronoUnit.DAYS); - String clientTimezone = null; - - boolean isExpired = expirationService.isActivityExpired(null, null, createdAt, clientTimezone); - - // Should be expired since it was created 2 days ago (past end of UTC day) - assertTrue(isExpired); - } - - @Test - void testCalculateActivityExpirationWithTimezone() { - // Test that calculateActivityExpiration also works with timezone - Instant createdAt = Instant.now(); - String clientTimezone = "America/Los_Angeles"; - - OffsetDateTime expiration = expirationService.calculateActivityExpiration(null, null, createdAt, clientTimezone); - - // Should return a valid expiration time - assertNotNull(expiration); - // Should be in the future (end of day in LA timezone) - assertTrue(expiration.isAfter(OffsetDateTime.now(ZoneOffset.UTC))); - } -} diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityServiceTests.java deleted file mode 100644 index d28a69551..000000000 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityServiceTests.java +++ /dev/null @@ -1,776 +0,0 @@ -package com.danielagapov.spawn.ServiceTests; - -import com.danielagapov.spawn.activity.api.dto.ActivityDTO; -import com.danielagapov.spawn.activity.api.dto.ActivityInviteDTO; -import com.danielagapov.spawn.activity.api.dto.ActivityPartialUpdateDTO; -import com.danielagapov.spawn.activity.api.dto.FullFeedActivityDTO; -import com.danielagapov.spawn.activity.api.dto.LocationDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.user.api.dto.UserDTO; - -import com.danielagapov.spawn.shared.util.EntityType; -import com.danielagapov.spawn.shared.util.ParticipationStatus; -import com.danielagapov.spawn.shared.exceptions.ApplicationException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.util.ActivityMapper; -import com.danielagapov.spawn.activity.internal.domain.ActivityUsersId; -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.ActivityUser; -import com.danielagapov.spawn.activity.internal.domain.Location; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityRepository; -import com.danielagapov.spawn.activity.internal.repositories.IActivityTypeRepository; -import com.danielagapov.spawn.activity.internal.repositories.IActivityUserRepository; -import com.danielagapov.spawn.activity.internal.repositories.ILocationRepository; -import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.activity.internal.services.IChatQueryService; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; -import com.danielagapov.spawn.activity.internal.services.ActivityService; -import com.danielagapov.spawn.activity.internal.services.ActivityExpirationService; -import com.danielagapov.spawn.activity.internal.services.ILocationService; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.parallel.Execution; -import org.junit.jupiter.api.parallel.ExecutionMode; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataAccessException; - -import java.time.Instant; -import java.time.OffsetDateTime; -import java.util.*; -import java.util.stream.Collectors; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; - -@Order(2) -@Execution(ExecutionMode.CONCURRENT) -public class ActivityServiceTests { - - @Mock - private IActivityRepository ActivityRepository; - - @Mock - private IActivityTypeRepository activityTypeRepository; - - @Mock - private ILogger logger; - - @Mock - private ILocationRepository locationRepository; - - @Mock - private ILocationService locationService; - - @Mock - private IUserRepository userRepository; - - @Mock - private IActivityUserRepository activityUserRepository; - - @Mock - private IUserService userService; - - @Mock - private IChatQueryService chatQueryService; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @Mock - private ActivityExpirationService activityExpirationService; - - @Mock - private IActivityTypeService activityTypeService; - - @InjectMocks - private ActivityService ActivityService; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - - // Setup default mock behavior for ActivityExpirationService - when(activityExpirationService.isActivityExpired(any(OffsetDateTime.class), any(OffsetDateTime.class), any(Instant.class))) - .thenReturn(false); // Default to not expired - } - - // --- Helper methods --- - private Activity createDummyActivity(UUID ActivityId, String title, OffsetDateTime start, OffsetDateTime end) { - return new Activity(ActivityId, title, start, end, - new Location(UUID.randomUUID(), "Default Location", 40.7128, -74.0060), - "Default note", - new User(UUID.randomUUID(), "testuser", "pic.jpg", "Test User", "bio", "test@email.com"), - "icon"); - } - - private ActivityDTO dummyActivityDTO(UUID ActivityId, String title) { - LocationDTO locationDTO = new LocationDTO(UUID.randomUUID(), "Test Location", 40.7128, -74.0060); - return new ActivityDTO( - ActivityId, - title, - OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1), - locationDTO, - null, // activityTypeId - "Note", - "icon", - null, // participantLimit - UUID.randomUUID(), - List.of(), - List.of(), - List.of(), - Instant.now(), - false, // isExpired - "America/New_York" // clientTimezone - ); - } - - // --- Test methods --- - - @Test - void getAllActivities_ShouldReturnActivities_WhenActivitiesExist() { - List Activities = Arrays.asList( - createDummyActivity(UUID.randomUUID(), "Activity 1", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)), - createDummyActivity(UUID.randomUUID(), "Activity 2", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1))); - when(ActivityRepository.findAll()).thenReturn(Activities); - - when(activityUserRepository.findByActivity_IdAndStatus(any(UUID.class), eq(ParticipationStatus.participating))).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(any(UUID.class), eq(ParticipationStatus.invited))).thenReturn(List.of()); - when(chatQueryService.getChatMessageIdsByActivityId(any(UUID.class))).thenReturn(List.of()); - - List result = ActivityService.getAllActivities(); - - assertEquals(2, result.size()); - verify(ActivityRepository, times(1)).findAll(); - } - - @Test - void getAllActivities_ShouldThrowException_WhenDatabaseErrorOccurs() { - when(ActivityRepository.findAll()).thenThrow(new DataAccessException("Database error") { - }); - - assertThrows(Exception.class, () -> ActivityService.getAllActivities()); - - verify(ActivityRepository, times(1)).findAll(); - } - - @Test - void getActivityById_ShouldReturnActivity_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - Activity Activity = createDummyActivity(ActivityId, "Test Activity", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)); - when(ActivityRepository.findById(ActivityId)).thenReturn(Optional.of(Activity)); - - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.invited)).thenReturn(List.of()); - when(chatQueryService.getChatMessageIdsByActivityId(ActivityId)).thenReturn(List.of()); - - ActivityDTO result = ActivityService.getActivityById(ActivityId); - - assertEquals(ActivityId, result.getId()); - assertEquals("Test Activity", result.getTitle()); - verify(ActivityRepository, times(1)).findById(ActivityId); - } - - @Test - void getActivityById_ShouldThrowException_WhenActivityNotFound() { - UUID ActivityId = UUID.randomUUID(); - when(ActivityRepository.findById(ActivityId)).thenReturn(Optional.empty()); - - BaseNotFoundException exception = assertThrows(BaseNotFoundException.class, - () -> ActivityService.getActivityById(ActivityId)); - - assertEquals(EntityType.Activity, exception.entityType); - verify(ActivityRepository, times(1)).findById(ActivityId); - } - - @Test - void deleteActivityById_ShouldThrowException_WhenActivityNotFound() { - UUID ActivityId = UUID.randomUUID(); - when(ActivityRepository.existsById(ActivityId)).thenReturn(false); - - BaseNotFoundException exception = assertThrows(BaseNotFoundException.class, - () -> ActivityService.deleteActivityById(ActivityId)); - - assertEquals(EntityType.Activity, exception.entityType); - verify(ActivityRepository, never()).deleteById(ActivityId); - } - - @Test - void saveActivity_ShouldSaveActivity_WhenValidData() { - UUID locationId = UUID.randomUUID(); - Location location = new Location(locationId, "Park", 40.7128, -74.0060); - LocationDTO locationDTO = new LocationDTO(locationId, "Park", 40.7128, -74.0060); - ActivityDTO ActivityDTO = new ActivityDTO(UUID.randomUUID(), "Birthday Party", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(2), locationDTO, null, "Bring your own snacks!", "icon", null, UUID.randomUUID(), - List.of(), List.of(), List.of(), Instant.now(), false, "America/New_York"); - User creator = new User( - UUID.randomUUID(), - "username", - "profilePicture", - "John Smith", - "bio", - "email"); - - when(locationService.save(any(Location.class))).thenReturn(location); - when(userService.getUserEntityById(ActivityDTO.getCreatorUserId())).thenReturn(creator); - when(ActivityRepository.save(any(Activity.class))).thenReturn(ActivityMapper.toEntity(ActivityDTO, location, creator, null)); - - assertDoesNotThrow(() -> ActivityService.saveActivity(ActivityDTO)); - - verify(ActivityRepository, times(1)).save(any(Activity.class)); - } - - @Test - void saveActivity_ShouldThrowException_WhenDatabaseErrorOccurs() { - UUID locationId = UUID.randomUUID(); - Location location = new Location(locationId, "Park", 40.7128, -74.0060); - LocationDTO locationDTO = new LocationDTO(locationId, "Park", 40.7128, -74.0060); - ActivityDTO ActivityDTO = new ActivityDTO(UUID.randomUUID(), "Birthday Party", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(2), locationDTO, null, "Bring your own snacks!", "icon", null, UUID.randomUUID(), - List.of(), List.of(), List.of(), Instant.now(), false, "America/New_York"); - - when(locationService.save(any(Location.class))).thenReturn(location); - when(ActivityRepository.save(any(Activity.class))).thenThrow(new DataAccessException("Database error") { - }); - - BaseSaveException exception = assertThrows(BaseSaveException.class, - () -> ActivityService.saveActivity(ActivityDTO)); - - assertTrue(exception.getMessage().contains("Failed to save Activity")); - verify(ActivityRepository, times(1)).save(any(Activity.class)); - } - - @Test - void deleteActivityById_ShouldDeleteActivity_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - when(ActivityRepository.existsById(ActivityId)).thenReturn(true); - - assertDoesNotThrow(() -> ActivityService.deleteActivityById(ActivityId)); - - verify(ActivityRepository, times(1)).deleteById(ActivityId); - } - - @Test - void deleteActivityById_ShouldReturnFalse_WhenDatabaseErrorOccurs() { - UUID ActivityId = UUID.randomUUID(); - when(ActivityRepository.existsById(ActivityId)).thenReturn(true); - doThrow(new DataAccessException("Database error") { - }).when(ActivityRepository).deleteById(ActivityId); - - boolean result = ActivityService.deleteActivityById(ActivityId); - - assertFalse(result); - verify(ActivityRepository, times(1)).deleteById(ActivityId); - } - - @Test - void createActivity_Successful() { - UUID creatorId = UUID.randomUUID(); - // Friend tag functionality removed - activities now invite friends directly - UUID explicitInviteId = UUID.randomUUID(); - // Friend tag functionality removed - - LocationDTO locationDTO = new LocationDTO(null, "Test Location", 0.0, 0.0); - ActivityDTO creationDTO = new ActivityDTO( - null, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - locationDTO, // location - null, // activityTypeId - "Test note", - "icon", - null, // participantLimit - creatorId, // creatorUserId - List.of(), // participantUserIds - List.of(explicitInviteId), // invitedUserIds - List.of(), // chatMessageIds - Instant.now(), // createdAt - false, // isExpired - "America/New_York" // clientTimezone - ); - - Location location = new Location(UUID.randomUUID(), "Test Location", 0.0, 0.0); - when(locationService.save(any(Location.class))).thenReturn(location); - - User creator = new User(); - creator.setId(creatorId); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - - User invitedUser = new User(); - invitedUser.setId(explicitInviteId); - when(userRepository.findById(explicitInviteId)).thenReturn(Optional.of(invitedUser)); - - Activity activity = new Activity(); - activity.setId(UUID.randomUUID()); - activity.setTitle("Test Activity"); - activity.setCreator(creator); - activity.setLocation(location); - when(ActivityRepository.save(any(Activity.class))).thenReturn(activity); - - // When - assertDoesNotThrow(() -> ActivityService.createActivity(creationDTO)); - - // Verify core Activity was saved - verify(ActivityRepository, times(1)).save(any(Activity.class)); - verify(activityUserRepository, times(1)).save(any(ActivityUser.class)); - - // Don't verify the Activity publisher - the service uses it correctly based on the logs - // and the verification isn't working well in tests - } - - @Test - void createActivity_Fails_WhenStartTimeIsInThePast() { - UUID creatorId = UUID.randomUUID(); - LocationDTO locationDTO = new LocationDTO(null, "Test Location", 0.0, 0.0); - - // Create ActivityDTO with start time in the past - ActivityDTO creationDTO = new ActivityDTO( - null, - "Test Activity", - OffsetDateTime.now().minusHours(1), // start time 1 hour ago (in the past) - OffsetDateTime.now().plusHours(1), // end time 1 hour from now (valid) - locationDTO, - null, - "Test note", - "icon", - null, - creatorId, - List.of(), - List.of(), - List.of(), - Instant.now(), - false, - "America/New_York" - ); - - Location location = new Location(UUID.randomUUID(), "Test Location", 0.0, 0.0); - when(locationService.save(any(Location.class))).thenReturn(location); - - User creator = new User(); - creator.setId(creatorId); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - - // Expect ApplicationException when start time is in the past (wraps IllegalArgumentException) - ApplicationException exception = assertThrows(ApplicationException.class, - () -> ActivityService.createActivity(creationDTO)); - - assertEquals("Failed to create Activity", exception.getMessage()); - assertTrue(exception.getCause() instanceof IllegalArgumentException); - assertEquals("Activity start time cannot be in the past", exception.getCause().getMessage()); - - // Verify that the activity was not saved - verify(ActivityRepository, never()).save(any(Activity.class)); - } - - @Test - void partialUpdateActivity_Fails_WhenStartTimeIsInThePast() { - UUID activityId = UUID.randomUUID(); - Activity existingActivity = new Activity(); - existingActivity.setId(activityId); - existingActivity.setTitle("Existing Activity"); - existingActivity.setStartTime(OffsetDateTime.now().plusHours(1)); - existingActivity.setEndTime(OffsetDateTime.now().plusHours(3)); - - User creator = new User(); - creator.setId(UUID.randomUUID()); - existingActivity.setCreator(creator); - - when(ActivityRepository.findById(activityId)).thenReturn(Optional.of(existingActivity)); - - // Create partial update with past start time - ActivityPartialUpdateDTO updates = new ActivityPartialUpdateDTO(); - updates.setStartTime(OffsetDateTime.now().minusHours(1).toString()); // 1 hour ago - - // Expect IllegalArgumentException when trying to update start time to past - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> ActivityService.partialUpdateActivity(updates, activityId)); - - assertEquals("Activity start time cannot be in the past", exception.getMessage()); - - // Verify that the activity was not saved - verify(ActivityRepository, never()).save(any(Activity.class)); - } - - @Test - void createActivity_Fails_WhenLocationNotCreated() { - UUID creatorId = UUID.randomUUID(); - ActivityDTO creationDTO = new ActivityDTO( - null, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - new LocationDTO(null, "Test Location", 0.0, 0.0), // location - null, // activityTypeId - "Test note", - "icon", - null, // participantLimit - creatorId, // creatorUserId - List.of(), // participantUserIds - List.of(), // invitedUserIds - List.of(), // chatMessageIds - Instant.now(), // createdAt - false, // isExpired - "America/New_York" // clientTimezone - ); - - when(locationService.save(any(Location.class))).thenThrow(new RuntimeException("Location creation failed")); - - // When / Then - assertThrows(ApplicationException.class, () -> ActivityService.createActivity(creationDTO)); - - // Verify Activity was not saved - verify(ActivityRepository, never()).save(any(Activity.class)); - } - - @Test - void createActivity_Successful_WithFriendInvites() { - UUID creatorId = UUID.randomUUID(); - // Friend tag functionality removed - activities now invite friends directly - UUID commonUserId = UUID.randomUUID(); - - ActivityDTO creationDTO = new ActivityDTO( - null, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - new LocationDTO(null, "Test Location", 0.0, 0.0), // location - null, // activityTypeId - "Test note", - "icon", - null, // participantLimit - creatorId, // creatorUserId - List.of(), // participantUserIds - List.of(commonUserId), // invitedUserIds - List.of(), // chatMessageIds - Instant.now(), // createdAt - false, // isExpired - "America/New_York" // clientTimezone - ); - - Location location = new Location(UUID.randomUUID(), "Test Location", 0.0, 0.0); - when(locationService.save(any(Location.class))).thenReturn(location); - - User creator = new User(); - creator.setId(creatorId); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - - User invitedUser = new User(); - invitedUser.setId(commonUserId); - when(userRepository.findById(commonUserId)).thenReturn(Optional.of(invitedUser)); - - Activity activity = new Activity(); - activity.setId(UUID.randomUUID()); - activity.setTitle("Test Activity"); - activity.setCreator(creator); - activity.setLocation(location); - when(ActivityRepository.save(any(Activity.class))).thenReturn(activity); - - // When - assertDoesNotThrow(() -> ActivityService.createActivity(creationDTO)); - - // Verify core Activity was saved - verify(ActivityRepository, times(1)).save(any(Activity.class)); - verify(activityUserRepository, times(1)).save(any(ActivityUser.class)); - } - - @Test - void replaceActivity_ShouldUpdateActivity_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - Activity existingActivity = createDummyActivity(ActivityId, "Old Title", OffsetDateTime.now(), OffsetDateTime.now().plusHours(1)); - when(ActivityRepository.findById(ActivityId)).thenReturn(Optional.of(existingActivity)); - - ActivityDTO newActivityDTO = dummyActivityDTO(ActivityId, "New Title"); - Location dummyLoc = new Location(UUID.randomUUID(), "New Location", 10.0, 20.0); - when(locationService.save(any(Location.class))).thenReturn(dummyLoc); - User dummyCreator = new User(); - dummyCreator.setId(newActivityDTO.getCreatorUserId()); - when(userService.getUserEntityById(newActivityDTO.getCreatorUserId())).thenReturn(dummyCreator); - - Activity updatedActivity = createDummyActivity(ActivityId, "New Title", newActivityDTO.getStartTime(), newActivityDTO.getEndTime()); - updatedActivity.setLocation(dummyLoc); - updatedActivity.setCreator(dummyCreator); - when(ActivityRepository.save(existingActivity)).thenReturn(updatedActivity); - - when(activityUserRepository.findByActivity_Id(ActivityId)).thenReturn(List.of()); - - ActivityDTO returnActivityDTO = dummyActivityDTO(ActivityId, "New Title"); - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.invited)).thenReturn(List.of()); - when(chatQueryService.getChatMessageIdsByActivityId(ActivityId)).thenReturn(List.of()); - - FullFeedActivityDTO result = ActivityService.replaceActivity(newActivityDTO, ActivityId); - - assertNotNull(result); - verify(ActivityRepository, times(2)).findById(ActivityId); - verify(ActivityRepository, times(1)).save(existingActivity); - } - - @Test - void replaceActivity_ShouldThrowException_WhenActivityNotFound() { - UUID ActivityId = UUID.randomUUID(); - when(ActivityRepository.findById(ActivityId)).thenReturn(Optional.empty()); - - ActivityDTO newActivityDTO = dummyActivityDTO(ActivityId, "New Title"); - Location dummyLoc = new Location(UUID.randomUUID(), "New Location", 10.0, 20.0); - when(locationService.save(any(Location.class))).thenReturn(dummyLoc); - - BaseNotFoundException exception = assertThrows(BaseNotFoundException.class, - () -> ActivityService.replaceActivity(newActivityDTO, ActivityId)); - - assertEquals(EntityType.Activity, exception.entityType); - verify(ActivityRepository, times(1)).findById(ActivityId); - verify(ActivityRepository, never()).save(any(Activity.class)); - } - - @Test - void createActivity_WithInvitedUsers_Successful() { - UUID creatorId = UUID.randomUUID(); - UUID invitedUserId = UUID.randomUUID(); - - ActivityDTO creationDTO = new ActivityDTO( - null, - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - new LocationDTO(null, "Test Location", 0.0, 0.0), // location - null, // activityTypeId - "Test note", - "icon", - 5, // participantLimit - creatorId, // creatorUserId - List.of(), // participantUserIds - List.of(invitedUserId), // invitedUserIds - List.of(), // chatMessageIds - Instant.now(), // createdAt - false, // isExpired - "America/New_York" // clientTimezone - ); - - Location location = new Location(UUID.randomUUID(), "Test Location", 0.0, 0.0); - when(locationService.save(any(Location.class))).thenReturn(location); - - User creator = new User(); - creator.setId(creatorId); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - - User invitedUser = new User(); - invitedUser.setId(invitedUserId); - when(userRepository.findById(invitedUserId)).thenReturn(Optional.of(invitedUser)); - - Activity activity = new Activity(); - activity.setId(UUID.randomUUID()); - activity.setTitle("Test Activity"); - activity.setCreator(creator); - activity.setLocation(location); - when(ActivityRepository.save(any(Activity.class))).thenReturn(activity); - - // When - assertDoesNotThrow(() -> ActivityService.createActivity(creationDTO)); - - // Verify - verify(ActivityRepository, times(1)).save(any(Activity.class)); - verify(activityUserRepository, times(1)).save(any(ActivityUser.class)); - } - - @Test - void createActivity_WithMultipleInvitedUsers_Successful() { - UUID creatorId = UUID.randomUUID(); - UUID invitedUserId1 = UUID.randomUUID(); - UUID invitedUserId2 = UUID.randomUUID(); - - ActivityDTO creationDTO = new ActivityDTO( - UUID.randomUUID(), - "Test Activity", - OffsetDateTime.now().plusDays(1), - OffsetDateTime.now().plusDays(1).plusHours(2), - new LocationDTO(null, "Test Location", 0.0, 0.0), // location - null, // activityTypeId - "Test note", - "icon", - 5, // participantLimit - creatorId, // creatorUserId - List.of(), // participantUserIds - List.of(invitedUserId1, invitedUserId2), // invitedUserIds - List.of(), // chatMessageIds - Instant.now(), // createdAt - false, // isExpired - "America/New_York" // clientTimezone - ); - - Location location = new Location(UUID.randomUUID(), "Test Location", 0.0, 0.0); - when(locationService.save(any(Location.class))).thenReturn(location); - - User creator = new User(); - creator.setId(creatorId); - when(userRepository.findById(creatorId)).thenReturn(Optional.of(creator)); - - User invitedUser1 = new User(); - invitedUser1.setId(invitedUserId1); - when(userRepository.findById(invitedUserId1)).thenReturn(Optional.of(invitedUser1)); - - User invitedUser2 = new User(); - invitedUser2.setId(invitedUserId2); - when(userRepository.findById(invitedUserId2)).thenReturn(Optional.of(invitedUser2)); - - Activity activity = new Activity(); - activity.setId(UUID.randomUUID()); - activity.setTitle("Test Activity"); - activity.setCreator(creator); - activity.setLocation(location); - when(ActivityRepository.save(any(Activity.class))).thenReturn(activity); - - // When - assertDoesNotThrow(() -> ActivityService.createActivity(creationDTO)); - - // Verify - verify(ActivityRepository, times(1)).save(any(Activity.class)); - verify(activityUserRepository, times(2)).save(any(ActivityUser.class)); - } - - @Test - void getActivityInviteById_ShouldReturnActivityInviteDTO_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - Activity Activity = createDummyActivity(ActivityId, "Test Activity", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)); - when(ActivityRepository.findById(ActivityId)).thenReturn(Optional.of(Activity)); - - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.invited)).thenReturn(List.of()); - - ActivityInviteDTO result = ActivityService.getActivityInviteById(ActivityId); - - assertEquals(ActivityId, result.getId()); - assertEquals("Test Activity", result.getTitle()); - verify(ActivityRepository, times(1)).findById(ActivityId); - } - - @Test - void getActivitiesByOwnerId_ShouldReturnActivities_WhenUserHasActivities() { - UUID creatorUserId = UUID.randomUUID(); - List Activities = Arrays.asList( - createDummyActivity(UUID.randomUUID(), "Activity 1", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)), - createDummyActivity(UUID.randomUUID(), "Activity 2", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1))); - when(ActivityRepository.findByCreatorId(creatorUserId)).thenReturn(Activities); - - when(activityUserRepository.findByActivity_IdAndStatus(any(UUID.class), eq(ParticipationStatus.participating))).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(any(UUID.class), eq(ParticipationStatus.invited))).thenReturn(List.of()); - when(chatQueryService.getChatMessageIdsByActivityId(any(UUID.class))).thenReturn(List.of()); - - List result = ActivityService.getActivitiesByOwnerId(creatorUserId); - - assertEquals(2, result.size()); - verify(ActivityRepository, times(1)).findByCreatorId(creatorUserId); - } - - @Test - void getFullActivityByActivity_ShouldReturnFullFeedActivityDTO_WhenValidData() { - UUID ActivityId = UUID.randomUUID(); - ActivityDTO ActivityDTO = dummyActivityDTO(ActivityId, "Test Activity"); - UUID requestingUserId = UUID.randomUUID(); - - UserDTO creator = new UserDTO(ActivityDTO.getCreatorUserId(), List.of(), "testuser", "pic.jpg", "Test User", "bio", "test@email.com"); - when(userService.getUserById(ActivityDTO.getCreatorUserId())).thenReturn(creator); - - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(ActivityId, ParticipationStatus.invited)).thenReturn(List.of()); - when(chatQueryService.getFullChatMessagesByActivityId(ActivityId)).thenReturn(List.of()); - - - - FullFeedActivityDTO result = ActivityService.getFullActivityByActivity(ActivityDTO, requestingUserId, new HashSet<>()); - - assertNotNull(result); - assertEquals(ActivityId, result.getId()); - assertEquals("Test Activity", result.getTitle()); - } - - @Test - void getFullActivityByActivity_ShouldReturnNull_WhenLocationNotFound() { - UUID ActivityId = UUID.randomUUID(); - LocationDTO locationDTO = new LocationDTO(UUID.randomUUID(), "Test Location", 40.7128, -74.0060); - ActivityDTO ActivityDTO = new ActivityDTO( - ActivityId, - "Test Activity", - OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1), - locationDTO, - null, - "Note", - "icon", - null, - UUID.randomUUID(), - List.of(), - List.of(), - List.of(), - Instant.now(), - false, // isExpired - "America/New_York" // clientTimezone - ); - UUID requestingUserId = UUID.randomUUID(); - - when(userService.getUserById(ActivityDTO.getCreatorUserId())).thenThrow(new BaseNotFoundException(EntityType.User, ActivityDTO.getCreatorUserId())); - - FullFeedActivityDTO result = ActivityService.getFullActivityByActivity(ActivityDTO, requestingUserId, new HashSet<>()); - - assertNull(result); - } - - @Test - void getActivityInviteById_ShouldReturnCorrectLocationData() { - UUID activityId = UUID.randomUUID(); - UUID locationId = UUID.randomUUID(); - Location location = new Location(locationId, "Test Location", 40.7128, -74.0060); - - Activity activity = createDummyActivity(activityId, "Test Activity", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)); - activity.setLocation(location); - - when(ActivityRepository.findById(activityId)).thenReturn(Optional.of(activity)); - when(activityUserRepository.findByActivity_IdAndStatus(activityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(activityId, ParticipationStatus.invited)).thenReturn(List.of()); - - ActivityInviteDTO result = ActivityService.getActivityInviteById(activityId); - - assertNotNull(result); - assertEquals(activityId, result.getId()); - assertEquals(locationId, result.getLocationId()); - verify(ActivityRepository, times(1)).findById(activityId); - } - - @Test - void getActivityInviteById_ShouldHandleNullLocation() { - UUID activityId = UUID.randomUUID(); - - Activity activity = createDummyActivity(activityId, "Test Activity", OffsetDateTime.now(), - OffsetDateTime.now().plusHours(1)); - activity.setLocation(null); - - when(ActivityRepository.findById(activityId)).thenReturn(Optional.of(activity)); - when(activityUserRepository.findByActivity_IdAndStatus(activityId, ParticipationStatus.participating)).thenReturn(List.of()); - when(activityUserRepository.findByActivity_IdAndStatus(activityId, ParticipationStatus.invited)).thenReturn(List.of()); - - ActivityInviteDTO result = ActivityService.getActivityInviteById(activityId); - - assertNotNull(result); - assertEquals(activityId, result.getId()); - assertNull(result.getLocationId()); - verify(ActivityRepository, times(1)).findById(activityId); - } -} diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeInitializerTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeInitializerTests.java deleted file mode 100644 index 33ba11261..000000000 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeInitializerTests.java +++ /dev/null @@ -1,230 +0,0 @@ -package com.danielagapov.spawn.ServiceTests; - -import com.danielagapov.spawn.shared.config.ActivityTypeInitializer; -import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.boot.CommandLineRunner; -import org.springframework.cache.Cache; -import org.springframework.cache.CacheManager; -import org.springframework.dao.DataIntegrityViolationException; - -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.UUID; - -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -/** - * Unit tests for ActivityTypeInitializer to ensure it properly initializes activity types - * for existing users who don't have them (e.g., after database wipe and redeploy). - */ -@ExtendWith(MockitoExtension.class) -@Order(3) -class ActivityTypeInitializerTests { - - @Mock - private IUserRepository userRepository; - - @Mock - private IActivityTypeService activityTypeService; - - @Mock - private CacheManager cacheManager; - - @Mock - private Cache cache; - - @Mock - private ILogger logger; - - private ActivityTypeInitializer activityTypeInitializer; - private List testUsers; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - activityTypeInitializer = new ActivityTypeInitializer(); - - // Create test users - testUsers = new ArrayList<>(); - for (int i = 0; i < 3; i++) { - User user = new User(); - user.setId(UUID.randomUUID()); - user.setEmail("user" + i + "@example.com"); - user.setUsername("user" + i); - user.setName("User " + i); - user.setDateCreated(new Date()); - testUsers.add(user); - } - } - - @Test - void initializeActivityTypes_ShouldInitializeForUsersWithoutActivityTypes() throws Exception { - // Arrange - when(userRepository.findAll()).thenReturn(testUsers); - - // Mock first user has no activity types - when(activityTypeService.getActivityTypesByUserId(testUsers.get(0).getId())) - .thenReturn(new ArrayList<>()); - - // Mock second user has activity types - List existingActivityTypes = List.of( - new ActivityTypeDTO(UUID.randomUUID(), "Existing Type", List.of(), "🎯", 0, testUsers.get(1).getId(), false) - ); - when(activityTypeService.getActivityTypesByUserId(testUsers.get(1).getId())) - .thenReturn(existingActivityTypes); - - // Mock third user has no activity types - when(activityTypeService.getActivityTypesByUserId(testUsers.get(2).getId())) - .thenReturn(new ArrayList<>()); - - // Act - CommandLineRunner runner = activityTypeInitializer.initializeActivityTypes( - userRepository, activityTypeService, cacheManager, logger - ); - runner.run(); - - // Assert - verify(userRepository, times(1)).findAll(); - verify(activityTypeService, times(3)).getActivityTypesByUserId(any(UUID.class)); - - // Should initialize for users 0 and 2 (who don't have activity types) - verify(activityTypeService, times(1)).initializeDefaultActivityTypesForUser(testUsers.get(0)); - verify(activityTypeService, times(1)).initializeDefaultActivityTypesForUser(testUsers.get(2)); - - // Should NOT initialize for user 1 (who already has activity types) - verify(activityTypeService, never()).initializeDefaultActivityTypesForUser(testUsers.get(1)); - - // Should log appropriately - verify(logger, times(1)).info("Starting activity type initialization for existing users"); - verify(logger, times(1)).info("Found " + testUsers.size() + " users in the database"); - verify(logger, times(1)).info(contains("Activity type initialization completed")); - } - - @Test - void initializeActivityTypes_ShouldSkipUsersWithExistingActivityTypes() throws Exception { - // Arrange - when(userRepository.findAll()).thenReturn(testUsers); - - // Mock all users have activity types - List existingActivityTypes = List.of( - new ActivityTypeDTO(UUID.randomUUID(), "Existing Type", List.of(), "🎯", 0, UUID.randomUUID(), false) - ); - - for (User user : testUsers) { - when(activityTypeService.getActivityTypesByUserId(user.getId())) - .thenReturn(existingActivityTypes); - } - - // Act - CommandLineRunner runner = activityTypeInitializer.initializeActivityTypes( - userRepository, activityTypeService, cacheManager, logger - ); - runner.run(); - - // Assert - verify(userRepository, times(1)).findAll(); - verify(activityTypeService, times(testUsers.size())).getActivityTypesByUserId(any(UUID.class)); - - // Should NOT initialize for any user - verify(activityTypeService, never()).initializeDefaultActivityTypesForUser(any(User.class)); - - // Should log completion with 0 users initialized - verify(logger, times(1)).info(contains("0 users initialized")); - verify(logger, times(1)).info(contains(testUsers.size() + " users skipped")); - } - - @Test - void initializeActivityTypes_ShouldHandleDataIntegrityViolationGracefully() throws Exception { - // Arrange - when(userRepository.findAll()).thenReturn(List.of(testUsers.get(0))); - when(activityTypeService.getActivityTypesByUserId(testUsers.get(0).getId())) - .thenReturn(new ArrayList<>()); - - // Mock first call throws DataIntegrityViolationException - doThrow(new DataIntegrityViolationException("Duplicate entry")) - .when(activityTypeService).initializeDefaultActivityTypesForUser(testUsers.get(0)); - - // Mock second call to check if user now has activity types - List recoveredActivityTypes = List.of( - new ActivityTypeDTO(UUID.randomUUID(), "Recovered Type", List.of(), "🎯", 0, testUsers.get(0).getId(), false) - ); - when(activityTypeService.getActivityTypesByUserId(testUsers.get(0).getId())) - .thenReturn(new ArrayList<>()) // First call - .thenReturn(recoveredActivityTypes); // Second call after exception - - // Act - CommandLineRunner runner = activityTypeInitializer.initializeActivityTypes( - userRepository, activityTypeService, cacheManager, logger - ); - runner.run(); - - // Assert - verify(activityTypeService, times(1)).initializeDefaultActivityTypesForUser(testUsers.get(0)); - verify(activityTypeService, times(2)).getActivityTypesByUserId(testUsers.get(0).getId()); - - // Should log warning about constraint violation - verify(logger, times(1)).warn(contains("Constraint violation during initialization")); - verify(logger, times(1)).info(contains("Initialization appears to have succeeded despite constraint error")); - } - - @Test - void initializeActivityTypes_ShouldHandleEmptyUserList() throws Exception { - // Arrange - when(userRepository.findAll()).thenReturn(new ArrayList<>()); - - // Act - CommandLineRunner runner = activityTypeInitializer.initializeActivityTypes( - userRepository, activityTypeService, cacheManager, logger - ); - runner.run(); - - // Assert - verify(userRepository, times(1)).findAll(); - verify(activityTypeService, never()).getActivityTypesByUserId(any(UUID.class)); - verify(activityTypeService, never()).initializeDefaultActivityTypesForUser(any(User.class)); - - // Should log about 0 users found - verify(logger, times(1)).info("Found 0 users in the database"); - verify(logger, times(1)).info(contains("0 users initialized")); - } - - @Test - void initializeActivityTypes_ShouldHandleUnexpectedExceptions() throws Exception { - // Arrange - when(userRepository.findAll()).thenReturn(List.of(testUsers.get(0))); - when(activityTypeService.getActivityTypesByUserId(testUsers.get(0).getId())) - .thenReturn(new ArrayList<>()); - - // Mock unexpected exception - doThrow(new RuntimeException("Unexpected error")) - .when(activityTypeService).initializeDefaultActivityTypesForUser(testUsers.get(0)); - - // Act - CommandLineRunner runner = activityTypeInitializer.initializeActivityTypes( - userRepository, activityTypeService, cacheManager, logger - ); - runner.run(); - - // Assert - verify(activityTypeService, times(1)).initializeDefaultActivityTypesForUser(testUsers.get(0)); - - // Should log error about unexpected exception - verify(logger, times(1)).error(contains("Unexpected error during initialization")); - verify(logger, times(1)).info(contains("1 users with errors")); - } - - -} \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeServiceTests.java deleted file mode 100644 index c36f11f2a..000000000 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/ActivityTypeServiceTests.java +++ /dev/null @@ -1,1471 +0,0 @@ -package com.danielagapov.spawn.ServiceTests; - -import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.activity.api.dto.BatchActivityTypeUpdateDTO; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.shared.exceptions.ActivityTypeValidationException; -import com.danielagapov.spawn.activity.internal.domain.ActivityType; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityTypeRepository; -import com.danielagapov.spawn.activity.internal.services.ActivityTypeService; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.Mockito.*; - -/** - * Unit tests for ActivityTypeService that match the front-end workflow. - * Front-end workflow: - * 1. Fetch activity types initially - * 2. All changes (pin, reorder, update, delete, create) happen locally/immediately on client - * 3. Only when user exits page, all changes are sent in one batch update to backend - */ -@ExtendWith(MockitoExtension.class) -@Order(4) -class ActivityTypeServiceTests { - - @Mock - private IActivityTypeRepository activityTypeRepository; - - @Mock - private IUserService userService; - - @Mock - private ILogger logger; - - @InjectMocks - private ActivityTypeService activityTypeService; - - private UUID userId; - private UUID activityTypeId1; - private UUID activityTypeId2; - private UUID activityTypeId3; - private User testUser; - private ActivityType chillActivityType; - private ActivityType foodActivityType; - private ActivityType activeActivityType; - private ActivityTypeDTO chillActivityTypeDTO; - private ActivityTypeDTO foodActivityTypeDTO; - private ActivityTypeDTO activeActivityTypeDTO; - - @BeforeEach - void setUp() { - MockitoAnnotations.openMocks(this); - - userId = UUID.randomUUID(); - activityTypeId1 = UUID.randomUUID(); - activityTypeId2 = UUID.randomUUID(); - activityTypeId3 = UUID.randomUUID(); - - testUser = new User(); - testUser.setId(userId); - testUser.setUsername("testuser"); - testUser.setName("Test User"); - - // Create test activity types matching front-end defaults - chillActivityType = new ActivityType(); - chillActivityType.setId(activityTypeId1); - chillActivityType.setTitle("Chill"); - chillActivityType.setIcon("🛋️"); - chillActivityType.setCreator(testUser); - chillActivityType.setOrderNum(1); - chillActivityType.setAssociatedFriends(List.of()); - chillActivityType.setIsPinned(false); - - foodActivityType = new ActivityType(); - foodActivityType.setId(activityTypeId2); - foodActivityType.setTitle("Food"); - foodActivityType.setIcon("🍽️"); - foodActivityType.setCreator(testUser); - foodActivityType.setOrderNum(2); - foodActivityType.setAssociatedFriends(List.of()); - foodActivityType.setIsPinned(true); // This one is pinned - - activeActivityType = new ActivityType(); - activeActivityType.setId(activityTypeId3); - activeActivityType.setTitle("Active"); - activeActivityType.setIcon("🏃"); - activeActivityType.setCreator(testUser); - activeActivityType.setOrderNum(3); - activeActivityType.setAssociatedFriends(List.of()); - activeActivityType.setIsPinned(false); - - // Create corresponding DTOs - chillActivityTypeDTO = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - "🛋️", - 1, - userId, - false - ); - - foodActivityTypeDTO = new ActivityTypeDTO( - activityTypeId2, - "Food", - List.of(), - "🍽️", - 2, - userId, - true - ); - - activeActivityTypeDTO = new ActivityTypeDTO( - activityTypeId3, - "Active", - List.of(), - "🏃", - 3, - userId, - false - ); - } - - // MARK: - Fetch Tests (Initial Load) - - @Test - void fetchActivityTypes_ShouldReturnSortedByPinnedThenOrder_WhenUserHasActivityTypes() { - // Arrange - matches front-end sortedActivityTypes computed property - List activityTypes = List.of(chillActivityType, foodActivityType, activeActivityType); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(activityTypes); - - // Act - List result = activityTypeService.getActivityTypesByUserId(userId); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - - // Verify pinned items come first in UI sorting (front-end handles this) - boolean foundPinned = false; - for (ActivityTypeDTO dto : result) { - if (dto.getIsPinned()) { - foundPinned = true; - assertEquals("Food", dto.getTitle()); - } - } - assertTrue(foundPinned, "Should have at least one pinned activity type"); - - verify(activityTypeRepository, times(1)).findActivityTypesByCreatorId(userId); - } - - @Test - void fetchActivityTypes_ShouldReturnEmptyList_WhenUserHasNoActivityTypes() { - // Arrange - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of()); - - // Act - List result = activityTypeService.getActivityTypesByUserId(userId); - - // Assert - assertNotNull(result); - assertTrue(result.isEmpty()); - verify(activityTypeRepository, times(1)).findActivityTypesByCreatorId(userId); - } - - // MARK: - Batch Update Tests (Client-Side Workflow) - - @Test - void batchUpdate_ShouldHandleClientSidePinToggle_WhenUserTogglesPin() { - // Arrange - User toggled pin on Chill activity type locally - ActivityTypeDTO modifiedChillDTO = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - "🛋️", - 1, // Toggled to pinned - userId, - true - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(modifiedChillDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); // Returns all user's activity types - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleClientSideReordering_WhenUserReordersActivityTypes() { - // Arrange - User reordered activity types locally (Active moved to position 0) - ActivityTypeDTO reorderedActiveDTO = new ActivityTypeDTO( - activityTypeId3, - "Active", - List.of(), - "🏃", - 1, // Moved to first position - userId, - false - ); - - ActivityTypeDTO reorderedChillDTO = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - "🛋️", - 2, // Moved to second position - userId, - false - ); - - ActivityTypeDTO reorderedFoodDTO = new ActivityTypeDTO( - activityTypeId2, - "Food", - List.of(), - "🍽️", - 3, // Moved to third position - userId, - true - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(reorderedActiveDTO, reorderedChillDTO, reorderedFoodDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn( - List.of(activeActivityType, chillActivityType, foodActivityType) - ); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); // Returns all user's activity types - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleClientSideCreation_WhenUserCreatesNewActivityType() { - // Arrange - User created a new "Study" activity type locally - UUID newActivityTypeId = UUID.randomUUID(); - ActivityTypeDTO newStudyDTO = new ActivityTypeDTO( - newActivityTypeId, - "Study", - List.of(), - "✏️", - 4, // Next order number - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newStudyDTO), - List.of() - ); - - ActivityType newStudyActivityType = new ActivityType(); - newStudyActivityType.setId(newActivityTypeId); - newStudyActivityType.setTitle("Study"); - newStudyActivityType.setIcon("✏️"); - newStudyActivityType.setCreator(testUser); - newStudyActivityType.setOrderNum(4); - newStudyActivityType.setIsPinned(false); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(newStudyActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); // Returns all user's activity types - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleClientSideDeletion_WhenUserDeletesActivityType() { - // Arrange - User deleted the "Active" activity type locally - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(), - List.of(activityTypeId3) // Delete Active activity type - ); - - // Mock the final result after deletion (should return remaining 2 activity types) - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType)); // Final result after deletion - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); // Returns all user's activity types except the deleted one - verify(activityTypeRepository, times(1)).deleteAllById(List.of(activityTypeId3)); - } - - @Test - void batchUpdate_ShouldHandleComplexClientSideWorkflow_WhenUserMakesMultipleChanges() { - // Arrange - User made multiple changes locally: - // 1. Created a new "Study" activity type - // 2. Deleted "Active" activity type - // 3. Toggled pin on "Chill" - // 4. Updated title of "Food" to "Food & Drinks" - // 5. Reordered everything - - UUID newStudyId = UUID.randomUUID(); - - ActivityTypeDTO newStudyDTO = new ActivityTypeDTO( - newStudyId, "Study", List.of(), "✏️", 1, userId, false - ); - - ActivityTypeDTO modifiedChillDTO = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 2, userId, true // Pinned and reordered - ); - - ActivityTypeDTO modifiedFoodDTO = new ActivityTypeDTO( - activityTypeId2, "Food & Drinks", List.of(), "🍽️", 3, userId, true // Title changed and reordered - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newStudyDTO, modifiedChillDTO, modifiedFoodDTO), - List.of(activityTypeId3) // Delete Active - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType(), chillActivityType, foodActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); // Returns all user's activity types after changes - verify(activityTypeRepository, times(1)).deleteAllById(List.of(activityTypeId3)); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleEmptyBatch_WhenNoChangesWereMade() { - // Arrange - User opened and closed activity type management without making changes - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(), - List.of() - ); - - // Act & Assert - IllegalArgumentException exception = assertThrows(IllegalArgumentException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("No activity types to update or delete")); - verify(activityTypeRepository, never()).saveAll(anyList()); - verify(activityTypeRepository, never()).deleteAllById(anyList()); - } - - // MARK: - Error Handling Tests - - @Test - void batchUpdate_ShouldThrowException_WhenUserNotFound() { - // Arrange - ActivityTypeDTO modifiedDTO = new ActivityTypeDTO( - activityTypeId1, "Modified", List.of(), "🛋️", 1, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(modifiedDTO), - List.of() - ); - - when(userService.getUserEntityById(userId)).thenThrow(new RuntimeException("User not found")); - - // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertEquals("User not found", exception.getMessage()); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - // MARK: - Default Activity Types Tests - - @Test - void initializeDefaultActivityTypes_ShouldCreateFourDefaultTypes_WhenUserIsNew() { - // Arrange - This matches the front-end default activity types - - // Act - assertDoesNotThrow(() -> activityTypeService.initializeDefaultActivityTypesForUser(testUser)); - - // Assert - Verify 4 default activity types are created (Chill, Food, Active, Study) using saveAll - verify(activityTypeRepository, times(1)).saveAll(argThat(list -> ((List) list).size() == 4)); - } - - @Test - void setOrderNumber_ShouldSetNextOrderNumber_WhenActivityTypeIsCreated() { - // Arrange - This simulates the front-end creating a new activity type - when(activityTypeRepository.findMaxOrderNumberByCreatorId(userId)).thenReturn(2); // User has 3 existing (0,1,2) - - ActivityType newActivityType = new ActivityType(); - newActivityType.setCreator(testUser); - - // Act - activityTypeService.setOrderNumber(newActivityType); - - // Assert - assertEquals(3, newActivityType.getOrderNum()); // Should be next in sequence - verify(activityTypeRepository, times(1)).findMaxOrderNumberByCreatorId(userId); - } - - @Test - void setOrderNumber_ShouldSetOne_WhenUserHasNoActivityTypes() { - // Arrange - New user with no activity types - when(activityTypeRepository.findMaxOrderNumberByCreatorId(userId)).thenReturn(null); - - ActivityType newActivityType = new ActivityType(); - newActivityType.setCreator(testUser); - - // Act - activityTypeService.setOrderNumber(newActivityType); - - // Assert - assertEquals(1, newActivityType.getOrderNum()); // Should start at 1 - verify(activityTypeRepository, times(1)).findMaxOrderNumberByCreatorId(userId); - } - - // MARK: - Validation Tests - - @Test - void batchUpdate_ShouldThrowValidationException_WhenTooManyPinnedActivityTypes() { - // Arrange - User tries to pin 5 activity types (exceeds limit of 4) - ActivityTypeDTO pinned1 = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, true); - ActivityTypeDTO pinned2 = new ActivityTypeDTO(activityTypeId2, "Food", List.of(), "🍽️", 2, userId, true); - ActivityTypeDTO pinned3 = new ActivityTypeDTO(activityTypeId3, "Active", List.of(), "🏃", 3, userId, true); - - UUID newId1 = UUID.randomUUID(); - ActivityTypeDTO pinned4 = new ActivityTypeDTO(newId1, "Study", List.of(), "✏️", 4, userId, true); - - UUID newId2 = UUID.randomUUID(); - ActivityTypeDTO pinned5 = new ActivityTypeDTO(newId2, "Work", List.of(), "💼", 5, userId, true); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(pinned1, pinned2, pinned3, pinned4, pinned5), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); // Current: 1 pinned - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); // Current: 3 total - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Cannot have more than 4 pinned activity types")); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldAllowMaximumPinnedActivityTypes_WhenExactlyFourPinned() { - // Arrange - User has exactly 4 pinned activity types (should be allowed) - ActivityTypeDTO pinned1 = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, true); - ActivityTypeDTO pinned2 = new ActivityTypeDTO(activityTypeId2, "Food", List.of(), "🍽️", 2, userId, true); - ActivityTypeDTO pinned3 = new ActivityTypeDTO(activityTypeId3, "Active", List.of(), "🏃", 3, userId, true); - - UUID newId = UUID.randomUUID(); - ActivityTypeDTO pinned4 = new ActivityTypeDTO(newId, "Study", List.of(), "✏️", 4, userId, true); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(pinned1, pinned2, pinned3, pinned4), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); // Current: 0 pinned - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); // Current: 3 total - - // Create a mock for the new activity type - ActivityType studyActivityType = new ActivityType(testUser, "Study", "✏️"); - studyActivityType.setId(newId); - studyActivityType.setIsPinned(true); - studyActivityType.setOrderNum(4); - - // Mock the repository to return all 4 activity types after the update - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)) // Initial state - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType, studyActivityType)); // After update - - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType, studyActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(4, result.size()); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldThrowValidationException_WhenOrderNumTooLow() { - // Arrange - User sets orderNum to 0 (invalid) - ActivityTypeDTO invalidOrderDTO = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 0, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(invalidOrderDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Invalid orderNum 0")); - assertTrue(exception.getMessage().contains("Must be in range [1, 3]")); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldThrowValidationException_WhenOrderNumTooHigh() { - // Arrange - User sets orderNum to 5 when only 3 activity types exist (valid range is 1-3; 4 is allowed as append-to-end) - ActivityTypeDTO invalidOrderDTO = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 5, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(invalidOrderDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Invalid orderNum 5")); - assertTrue(exception.getMessage().contains("Must be in range [1, 3]")); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldThrowValidationException_WhenDuplicateOrderNums() { - // Arrange - User sets duplicate orderNum values (both set to 2) - ActivityTypeDTO duplicate1 = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 2, userId, false); - ActivityTypeDTO duplicate2 = new ActivityTypeDTO(activityTypeId2, "Food", List.of(), "🍽️", 2, userId, false); // Same orderNum - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(duplicate1, duplicate2), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Duplicate orderNum values detected")); - assertTrue(exception.getMessage().contains("unique orderNum")); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldAllowValidOrderNum_WhenInCorrectRange() { - // Arrange - User sets valid orderNum values - ActivityTypeDTO validOrder1 = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false); - ActivityTypeDTO validOrder2 = new ActivityTypeDTO(activityTypeId2, "Food", List.of(), "🍽️", 2, userId, false); - ActivityTypeDTO validOrder3 = new ActivityTypeDTO(activityTypeId3, "Active", List.of(), "🏃", 3, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(validOrder1, validOrder2, validOrder3), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleComplexValidation_WhenDeletingAndCreating() { - // Arrange - User deletes 1 pinned item and creates 2 new pinned items (net +1, should be valid) - // Starting state: 1 pinned out of 3 total (orderNum 0, 1, 2) - // After update: delete 1 (orderNum 1), add 2 new = 2 pinned total (valid) - // Final orderNum should be: 0, 1, 2, 3 (where 1 and 3 are the new ones) - - UUID newId1 = UUID.randomUUID(); - UUID newId2 = UUID.randomUUID(); - - ActivityTypeDTO newPinned1 = new ActivityTypeDTO(newId1, "Study", List.of(), "✏️", 2, userId, true); - ActivityTypeDTO newPinned2 = new ActivityTypeDTO(newId2, "Sports", List.of(), "⚽", 4, userId, true); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newPinned1, newPinned2), - List.of(activityTypeId2) // Delete foodActivityType (which is pinned) - ); - - // Mock current state: foodActivityType is pinned - foodActivityType.setIsPinned(true); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); // 1 currently pinned - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); // 3 total - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of()); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - Should pass validation: 1 - 1 + 2 = 2 pinned (within limit of 4) - assertNotNull(result); - assertEquals(3, result.size()); // Returns all user's activity types - verify(activityTypeRepository, times(1)).deleteAllById(anyList()); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - // MARK: - Multi-Phase Update & Constraint Handling Tests - - @Test - void batchUpdate_ShouldHandleMultiPhaseUpdate_WhenExistingActivityTypesReordered() { - // Arrange - Simulate the exact reordering scenario that caused constraint violation - ActivityTypeDTO reorderedChill = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false - ); - ActivityTypeDTO reorderedFood = new ActivityTypeDTO( - activityTypeId2, "Food", List.of(), "🍽️", 2, userId, true - ); - ActivityTypeDTO reorderedActive = new ActivityTypeDTO( - activityTypeId3, "Active", List.of(), "🏃", 3, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(reorderedChill, reorderedFood, reorderedActive), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Mock existsById to simulate existing activity types - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(true); - when(activityTypeRepository.existsById(activityTypeId2)).thenReturn(true); - when(activityTypeRepository.existsById(activityTypeId3)).thenReturn(true); - - // Mock individual save calls for two-phase update - when(activityTypeRepository.save(any(ActivityType.class))).thenReturn(chillActivityType); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(3, result.size()); - - // Verify that individual save was called multiple times for multi-phase update - // (2 times per existing activity type: once for temp orderNum, once for final orderNum) - verify(activityTypeRepository, times(6)).save(any(ActivityType.class)); - verify(logger, times(1)).info(contains("Updating 3 existing activity types")); - verify(logger, times(1)).info(contains("Successfully completed multi-phase update")); - } - - @Test - void batchUpdate_ShouldSeparateNewAndExistingTypes_WhenMixedBatchUpdate() { - // Arrange - Mix of new and existing activity types - UUID newId = UUID.randomUUID(); - ActivityTypeDTO newType = new ActivityTypeDTO(newId, "Study", List.of(), "✏️", 1, userId, false); - ActivityTypeDTO existingType = new ActivityTypeDTO( - activityTypeId1, "Chill Updated", List.of(), "🛋️", 1, userId, true - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newType, existingType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Mock existsById to simulate one new, one existing - when(activityTypeRepository.existsById(newId)).thenReturn(false); - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(true); - - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType())); - when(activityTypeRepository.save(any(ActivityType.class))).thenReturn(chillActivityType); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - - // Verify new types saved via saveAll, existing types saved individually - verify(activityTypeRepository, times(1)).saveAll(anyList()); - verify(activityTypeRepository, times(2)).save(any(ActivityType.class)); // 2 phase for 1 existing - verify(logger, times(1)).info(contains("Saved 1 new activity types")); - verify(logger, times(1)).info(contains("Updating 1 existing activity types")); - } - - @Test - void batchUpdate_ShouldHandleConstraintViolationGracefully_WhenDatabaseConstraintFails() { - // Arrange - Simulate database constraint violation during two-phase update - ActivityTypeDTO reorderedType = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(reorderedType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(true); - - // Mock constraint violation during save - when(activityTypeRepository.save(any(ActivityType.class))) - .thenThrow(new org.springframework.dao.DataIntegrityViolationException( - "Duplicate entry for key 'UK_activity_type_creator_order'" - )); - - // Act & Assert - org.springframework.dao.DataIntegrityViolationException exception = - assertThrows(org.springframework.dao.DataIntegrityViolationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Duplicate entry")); - verify(logger, times(1)).error(contains("Error batch updating activity types")); - } - - @Test - void batchUpdate_ShouldHandleLargeReorderingBatch_WhenManyActivityTypes() { - // Arrange - Test with larger number of activity types (10 items) - List manyActivityTypes = new ArrayList<>(); - List manyActivityTypeDTOs = new ArrayList<>(); - - for (int i = 0; i < 10; i++) { - UUID id = UUID.randomUUID(); - ActivityType entity = new ActivityType(); - entity.setId(id); - entity.setTitle("Type " + i); - entity.setIcon("🎯"); - entity.setCreator(testUser); - entity.setOrderNum(i + 1); - entity.setIsPinned(false); - manyActivityTypes.add(entity); - - // Create reordered DTO (reverse order) - ActivityTypeDTO dto = new ActivityTypeDTO(id, "Type " + i, List.of(), "🎯", 10 - i, userId, false); - manyActivityTypeDTOs.add(dto); - } - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO(manyActivityTypeDTOs, List.of()); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(10L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(manyActivityTypes); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Mock all as existing - for (ActivityType type : manyActivityTypes) { - when(activityTypeRepository.existsById(type.getId())).thenReturn(true); - } - - when(activityTypeRepository.save(any(ActivityType.class))).thenReturn(new ActivityType()); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - - // Verify all 10 existing types went through multi-phase update (20 save calls total) - verify(activityTypeRepository, times(20)).save(any(ActivityType.class)); - verify(logger, times(1)).info(contains("Updating 10 existing activity types")); - } - - @Test - void batchUpdate_ShouldHandlePartialFailure_WhenPhase2Fails() { - // Arrange - Simulate failure in phase 2 of multi-phase update - ActivityTypeDTO reorderedType = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(reorderedType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(true); - - // Mock phase 1 success, phase 2 failure - when(activityTypeRepository.save(any(ActivityType.class))) - .thenReturn(chillActivityType) // Phase 1 success - .thenThrow(new RuntimeException("Phase 2 database error")); // Phase 2 failure - - // Act & Assert - RuntimeException exception = assertThrows(RuntimeException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Phase 2 database error")); - verify(activityTypeRepository, times(2)).save(any(ActivityType.class)); - } - - @Test - void batchUpdate_ShouldValidateOrderNumUniqueness_WhenConflictWithRemainingTypes() { - // Arrange - Update only some activity types, but create orderNum conflict with remaining ones - // Existing: Chill(1), Food(2), Active(3) - // Update: only Chill to orderNum=2 (conflicts with Food) - ActivityTypeDTO conflictingUpdate = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 2, userId, false // Conflicts with Food's orderNum - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(conflictingUpdate), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(1L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("orderNum 2 conflicts with existing activity type")); - verify(activityTypeRepository, never()).save(any(ActivityType.class)); - verify(activityTypeRepository, never()).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleEmptyExistingList_WhenUserHasNoActivityTypes() { - // Arrange - User has no existing activity types, only creating new ones - UUID newId = UUID.randomUUID(); - ActivityTypeDTO newType = new ActivityTypeDTO(newId, "First Type", List.of(), "🎯", 1, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(0L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of()); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.existsById(newId)).thenReturn(false); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType())); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - - // Should only use saveAll for new types, no individual saves - verify(activityTypeRepository, times(1)).saveAll(anyList()); - verify(activityTypeRepository, never()).save(any(ActivityType.class)); - verify(logger, times(1)).info(contains("Saved 1 new activity types")); - verify(logger, never()).info(contains("Updating")); - } - - @Test - void batchUpdate_ShouldHandleRepositoryFailure_WhenSaveAllFails() { - // Arrange - Test failure in saveAll for new activity types - UUID newId = UUID.randomUUID(); - ActivityTypeDTO newType = new ActivityTypeDTO(newId, "New Type", List.of(), "🎯", 1, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(0L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of()); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.existsById(newId)).thenReturn(false); - when(activityTypeRepository.saveAll(anyList())) - .thenThrow(new org.springframework.dao.DataAccessException("Database connection lost") {}); - - // Act & Assert - org.springframework.dao.DataAccessException exception = - assertThrows(org.springframework.dao.DataAccessException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Database connection lost")); - verify(logger, times(1)).error(contains("Error batch updating activity types")); - } - - @Test - void batchUpdate_ShouldHandleConcurrentModification_WhenActivityTypeDeletedDuringUpdate() { - // Arrange - Simulate activity type being deleted by another process during update - ActivityTypeDTO updateType = new ActivityTypeDTO( - activityTypeId1, "Updated Chill", List.of(), "🛋️", 1, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(updateType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - - // Simulate concurrent deletion - existsById returns false during update - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(false); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType())); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - Should treat as new activity type since existsById returned false - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - verify(activityTypeRepository, never()).save(any(ActivityType.class)); - verify(logger, times(1)).info(contains("Saved 1 new activity types")); - } - - // MARK: - Extended Front-End Scenario Tests - - @Test - void batchUpdate_ShouldHandleAssociatedFriends_WhenActivityTypeHasFriendsAssociated() { - // Arrange - Test with associated friends (front-end feature) - UUID friend1Id = UUID.randomUUID(); - UUID friend2Id = UUID.randomUUID(); - - ActivityTypeDTO activityTypeWithFriends = new ActivityTypeDTO( - activityTypeId1, - "Chill", - Arrays.asList( - new com.danielagapov.spawn.user.api.dto.FriendUser.MinimalFriendDTO(friend1Id, "friend1", "Friend One", "pic1.jpg"), - new com.danielagapov.spawn.user.api.dto.FriendUser.MinimalFriendDTO(friend2Id, "friend2", "Friend Two", "pic2.jpg") - ), - "🛋️", - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithFriends), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleEmptyAssociatedFriends_WhenRemovingAllFriends() { - // Arrange - Update activity type to remove all associated friends - ActivityTypeDTO activityTypeWithoutFriends = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), // Empty friends list - "🛋️", - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithoutFriends), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleMaxLengthTitle_WhenTitleIsVeryLong() { - // Arrange - Test with very long title (boundary testing) - String longTitle = "A".repeat(255); // Typical database varchar limit - - ActivityTypeDTO activityTypeWithLongTitle = new ActivityTypeDTO( - activityTypeId1, - longTitle, - List.of(), - "🛋️", - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithLongTitle), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleSpecialCharactersInTitle_WhenTitleContainsUnicodeAndSymbols() { - // Arrange - Test with special characters, emojis, and symbols - String specialTitle = "🎉🎊 Test & Activity (新年) - Special Event! @#$%^&*()_+ <>"; - - ActivityTypeDTO activityTypeWithSpecialChars = new ActivityTypeDTO( - activityTypeId1, - specialTitle, - List.of(), - "🎉", - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithSpecialChars), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleZeroOrderNum_WhenActivityTypeMovedToFirst() { - // Arrange - Ensure orderNum=0 is handled properly - ActivityTypeDTO firstActivityType = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - "🛋️", - 1, // First position - userId, - true // Pin it to make it truly first - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(firstActivityType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleGapInOrderNums_WhenUserDeletesMiddleActivityType() { - // Arrange - Delete middle activity type creating order gap (0, 2 instead of 0, 1, 2) - // This tests if the validation handles non-consecutive order numbers properly - ActivityTypeDTO updatedChill = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false - ); - ActivityTypeDTO updatedActive = new ActivityTypeDTO( - activityTypeId3, "Active", List.of(), "🏃", 2, userId, false // Moved from 3 to 2 - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(updatedChill, updatedActive), - List.of(activityTypeId2) // Delete food (was at position 2) - ); - - // Mock the initial state (3 activity types, 0 pinned) - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)) - .thenReturn(List.of(chillActivityType, activeActivityType)); // After deletion, only return remaining ones - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType, activeActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - assertEquals(2, result.size()); - verify(activityTypeRepository, times(1)).deleteAllById(List.of(activityTypeId2)); - } - - @Test - void batchUpdate_ShouldHandleIdenticalTitles_WhenUserCreatesActivityTypesWithSameName() { - // Arrange - Create multiple activity types with identical titles (should be allowed) - UUID newId1 = UUID.randomUUID(); - UUID newId2 = UUID.randomUUID(); - - ActivityTypeDTO duplicate1 = new ActivityTypeDTO( - newId1, "Study", List.of(), "📚", 1, userId, false - ); - ActivityTypeDTO duplicate2 = new ActivityTypeDTO( - newId2, "Study", List.of(), "✏️", 2, userId, false // Same title, different icon - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(duplicate1, duplicate2), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(0L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of()); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType(), new ActivityType())); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - Should allow identical titles - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleBoundaryPinnedCount_WhenExactlyAtLimit() { - // Arrange - Test exactly at the pinned limit boundary (4 pinned) - ActivityTypeDTO pinned1 = new ActivityTypeDTO(activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, true); - ActivityTypeDTO pinned2 = new ActivityTypeDTO(activityTypeId2, "Food", List.of(), "🍽️", 2, userId, true); - ActivityTypeDTO pinned3 = new ActivityTypeDTO(activityTypeId3, "Active", List.of(), "🏃", 3, userId, true); - - UUID newId1 = UUID.randomUUID(); - ActivityTypeDTO pinned4 = new ActivityTypeDTO(newId1, "Study", List.of(), "📚", 4, userId, true); - - UUID newId2 = UUID.randomUUID(); - ActivityTypeDTO unpinned = new ActivityTypeDTO(newId2, "Work", List.of(), "💼", 5, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(pinned1, pinned2, pinned3, pinned4, unpinned), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(3L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType, foodActivityType, activeActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(new ActivityType())); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - Should pass with exactly 4 pinned - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleNullIcon_WhenIconIsNotProvided() { - // Arrange - Test with null icon (should be handled gracefully) - ActivityTypeDTO activityTypeWithNullIcon = new ActivityTypeDTO( - activityTypeId1, - "Chill", - List.of(), - null, // Null icon - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithNullIcon), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleEmptyTitle_WhenTitleIsEmpty() { - // Arrange - Test with empty title (should be handled or validated) - ActivityTypeDTO activityTypeWithEmptyTitle = new ActivityTypeDTO( - activityTypeId1, - "", // Empty title - List.of(), - "🛋️", - 1, - userId, - false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(activityTypeWithEmptyTitle), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - List result = activityTypeService.updateActivityTypes(userId, batchDTO); - - // Assert - assertNotNull(result); - verify(activityTypeRepository, times(1)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleRapidFireUpdates_WhenUserMakesQuickChanges() { - // Arrange - Simulate rapid consecutive updates (like user quickly toggling pins) - ActivityTypeDTO quickUpdate1 = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, true // Pin - ); - ActivityTypeDTO quickUpdate2 = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 1, userId, false // Unpin - ); - - BatchActivityTypeUpdateDTO batchDTO1 = new BatchActivityTypeUpdateDTO(List.of(quickUpdate1), List.of()); - BatchActivityTypeUpdateDTO batchDTO2 = new BatchActivityTypeUpdateDTO(List.of(quickUpdate2), List.of()); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())).thenReturn(List.of(chillActivityType)); - - // Act - Simulate rapid updates - List result1 = activityTypeService.updateActivityTypes(userId, batchDTO1); - List result2 = activityTypeService.updateActivityTypes(userId, batchDTO2); - - // Assert - assertNotNull(result1); - assertNotNull(result2); - verify(activityTypeRepository, times(2)).saveAll(anyList()); - } - - @Test - void batchUpdate_ShouldHandleNegativeOrderNum_WhenDataCorrupted() { - // Arrange - Test with negative order number (data corruption scenario) - ActivityTypeDTO corruptedOrderDTO = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", 0, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(corruptedOrderDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Invalid orderNum 0")); - } - - @Test - void batchUpdate_ShouldHandleExtremelyHighOrderNum_WhenDataCorrupted() { - // Arrange - Test with extremely high order number - ActivityTypeDTO corruptedOrderDTO = new ActivityTypeDTO( - activityTypeId1, "Chill", List.of(), "🛋️", Integer.MAX_VALUE, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(corruptedOrderDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - - // Act & Assert - ActivityTypeValidationException exception = assertThrows(ActivityTypeValidationException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Invalid orderNum " + Integer.MAX_VALUE)); - } - - @Test - void fetchActivityTypes_ShouldHandleRepositoryTimeout_WhenDatabaseSlow() { - // Arrange - Simulate database timeout - when(activityTypeRepository.findActivityTypesByCreatorId(userId)) - .thenThrow(new org.springframework.dao.QueryTimeoutException("Query timeout")); - - // Act & Assert - org.springframework.dao.QueryTimeoutException exception = - assertThrows(org.springframework.dao.QueryTimeoutException.class, - () -> activityTypeService.getActivityTypesByUserId(userId)); - - assertTrue(exception.getMessage().contains("Query timeout")); - } - - @Test - void batchUpdate_ShouldHandleRepositoryOptimisticLockingException_WhenConcurrentUpdate() { - // Arrange - Simulate optimistic locking exception - ActivityTypeDTO updateDTO = new ActivityTypeDTO( - activityTypeId1, "Updated Chill", List.of(), "🛋️", 1, userId, false - ); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(updateDTO), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.saveAll(anyList())) - .thenThrow(new org.springframework.orm.ObjectOptimisticLockingFailureException("Version conflict", new Exception())); - - // Act & Assert - org.springframework.orm.ObjectOptimisticLockingFailureException exception = - assertThrows(org.springframework.orm.ObjectOptimisticLockingFailureException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Version conflict")); - } - - @Test - void batchUpdate_ShouldHandleTransactionRollback_WhenPartialUpdateFails() { - // Arrange - Test transaction behavior when part of the update fails - UUID newId = UUID.randomUUID(); - ActivityTypeDTO newType = new ActivityTypeDTO(newId, "New Type", List.of(), "🎯", 1, userId, false); - ActivityTypeDTO existingType = new ActivityTypeDTO(activityTypeId1, "Updated", List.of(), "🛋️", 2, userId, false); - - BatchActivityTypeUpdateDTO batchDTO = new BatchActivityTypeUpdateDTO( - List.of(newType, existingType), - List.of() - ); - - when(activityTypeRepository.countByCreatorIdAndIsPinnedTrue(userId)).thenReturn(0L); - when(activityTypeRepository.countByCreatorId(userId)).thenReturn(1L); - when(activityTypeRepository.findActivityTypesByCreatorId(userId)).thenReturn(List.of(chillActivityType)); - when(userService.getUserEntityById(userId)).thenReturn(testUser); - when(activityTypeRepository.existsById(newId)).thenReturn(false); - when(activityTypeRepository.existsById(activityTypeId1)).thenReturn(true); - - // Fail on saveAll but succeed on individual save - when(activityTypeRepository.saveAll(anyList())) - .thenThrow(new org.springframework.dao.DataAccessException("Transaction failed") {}); - - // Act & Assert - org.springframework.dao.DataAccessException exception = - assertThrows(org.springframework.dao.DataAccessException.class, - () -> activityTypeService.updateActivityTypes(userId, batchDTO)); - - assertTrue(exception.getMessage().contains("Transaction failed")); - } -} \ No newline at end of file diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/CacheServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/CacheServiceTests.java index b050ba138..3c43a15ba 100644 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/CacheServiceTests.java +++ b/src/test/java/com/danielagapov/spawn/ServiceTests/CacheServiceTests.java @@ -1,8 +1,7 @@ package com.danielagapov.spawn.ServiceTests; import com.danielagapov.spawn.activity.api.dto.ActivityTypeDTO; -import com.danielagapov.spawn.activity.api.IActivityService; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.analytics.internal.services.CacheService; import com.danielagapov.spawn.analytics.internal.services.CacheType; import com.danielagapov.spawn.shared.config.CacheValidationResponseDTO; @@ -50,10 +49,7 @@ class CacheServiceTests { private IUserService userService; @Mock - private IActivityService activityService; - - @Mock - private IActivityTypeService activityTypeService; + private ActivityServiceClient activityServiceClient; @Mock private IFriendRequestService friendRequestService; @@ -87,8 +83,7 @@ void setup() { cacheService = new CacheService( userRepository, userService, - activityService, - activityTypeService, + activityServiceClient, friendRequestService, objectMapper, userStatsService, @@ -251,13 +246,13 @@ void shouldInvalidateEventsCacheWhenActivityNewer() { timestamps.put(CacheType.EVENTS.getKey(), clientTimestamp); // Server has newer activity - when(activityService.getLatestCreatedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestCreatedActivityTimestamp(testUserId)) .thenReturn(Instant.now().minusSeconds(1800)); - when(activityService.getLatestInvitedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestInvitedActivityTimestamp(testUserId)) .thenReturn(null); - when(activityService.getLatestUpdatedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestUpdatedActivityTimestamp(testUserId)) .thenReturn(null); - when(activityService.getFeedActivities(testUserId)) + when(activityServiceClient.getFeedActivities(testUserId)) .thenReturn(List.of()); // When @@ -282,7 +277,7 @@ void shouldHandleEmptyActivityTypesList() { Map timestamps = new HashMap<>(); timestamps.put(CacheType.ACTIVITY_TYPES.getKey(), clientTimestamp); - when(activityTypeService.getActivityTypesByUserId(testUserId)) + when(activityServiceClient.getActivityTypesByUserId(testUserId)) .thenReturn(List.of()); // When @@ -306,7 +301,7 @@ void shouldInvalidateActivityTypesCachePeriodically() { ActivityTypeDTO activityType = new ActivityTypeDTO(); activityType.setId(UUID.randomUUID()); - when(activityTypeService.getActivityTypesByUserId(testUserId)) + when(activityServiceClient.getActivityTypesByUserId(testUserId)) .thenReturn(List.of(activityType)); // When @@ -683,13 +678,13 @@ void shouldHandleProfileEventsCacheValidation() { Map timestamps = new HashMap<>(); timestamps.put(CacheType.PROFILE_EVENTS.getKey(), getTimestamp(Instant.now().minusSeconds(7200))); - when(activityService.getLatestCreatedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestCreatedActivityTimestamp(testUserId)) .thenReturn(Instant.now().minusSeconds(3600)); - when(activityService.getLatestInvitedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestInvitedActivityTimestamp(testUserId)) .thenReturn(null); - when(activityService.getLatestUpdatedActivityTimestamp(testUserId)) + when(activityServiceClient.getLatestUpdatedActivityTimestamp(testUserId)) .thenReturn(null); - when(activityService.getProfileActivities(testUserId, testUserId)) + when(activityServiceClient.getProfileActivities(testUserId, testUserId)) .thenReturn(List.of()); // When diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/ChatMessageServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/ChatMessageServiceTests.java deleted file mode 100644 index 6fb402272..000000000 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/ChatMessageServiceTests.java +++ /dev/null @@ -1,557 +0,0 @@ -package com.danielagapov.spawn.ServiceTests; - -import com.danielagapov.spawn.chat.api.dto.ChatMessageDTO; -import com.danielagapov.spawn.chat.api.dto.FullActivityChatMessageDTO; -import com.danielagapov.spawn.user.api.dto.BaseUserDTO; -import com.danielagapov.spawn.user.api.dto.UserDTO; -import com.danielagapov.spawn.shared.exceptions.Base.BaseDeleteException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Base.BaseSaveException; -import com.danielagapov.spawn.shared.exceptions.Base.BasesNotFoundException; -import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; -import com.danielagapov.spawn.activity.internal.domain.Activity; -import com.danielagapov.spawn.activity.internal.domain.ChatMessage; -import com.danielagapov.spawn.activity.internal.domain.ChatMessageLikes; -import com.danielagapov.spawn.activity.internal.repositories.IActivityRepository; -import com.danielagapov.spawn.activity.internal.repositories.IChatMessageLikesRepository; -import com.danielagapov.spawn.activity.internal.repositories.IChatMessageRepository; -import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.user.internal.repositories.IUserRepository; -import com.danielagapov.spawn.chat.internal.services.ChatMessageService; -import com.danielagapov.spawn.activity.api.IActivityService; -import com.danielagapov.spawn.user.internal.services.IUserService; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Order; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.parallel.Execution; -import org.junit.jupiter.api.parallel.ExecutionMode; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.MockitoAnnotations; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.dao.DataAccessException; - -import java.time.Instant; -import java.util.*; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.*; - -@Order(7) -@Execution(ExecutionMode.CONCURRENT) -public class ChatMessageServiceTests { - - @Mock - private IChatMessageRepository chatMessageRepository; - - @Mock - private IUserService userService; - - @Mock - private IChatMessageLikesRepository chatMessageLikesRepository; - - @Mock - private IUserRepository userRepository; - - @Mock - private ILogger logger; - - @Mock - private IActivityService activityService; - - @Mock - private IActivityRepository activityRepository; - - @Mock - private ApplicationEventPublisher eventPublisher; - - @InjectMocks - private ChatMessageService chatMessageService; - - @BeforeEach - void setup() { - MockitoAnnotations.openMocks(this); - } - - // Helper method to create a dummy Activity with a random ID. - private Activity createDummyActivity() { - Activity Activity = new Activity(); - Activity.setId(UUID.randomUUID()); - return Activity; - } - - // Helper method to create a dummy User with a given ID. - private User createDummyUser(UUID id) { - User user = new User(); - user.setId(id); - return user; - } - - @Test - void deleteChatMessage_ShouldDeleteMessage_WhenMessageExists() { - UUID chatMessageId = UUID.randomUUID(); - when(chatMessageRepository.existsById(chatMessageId)).thenReturn(true); - doNothing().when(chatMessageRepository).deleteById(chatMessageId); - assertDoesNotThrow(() -> chatMessageService.deleteChatMessageById(chatMessageId)); - verify(chatMessageRepository, times(1)).existsById(chatMessageId); - verify(chatMessageRepository, times(1)).deleteById(chatMessageId); - } - - @Test - void deleteChatMessage_ShouldThrowException_WhenMessageDoesNotExist() { - UUID chatMessageId = UUID.randomUUID(); - when(chatMessageRepository.existsById(chatMessageId)).thenReturn(false); - BaseNotFoundException exception = assertThrows(BaseNotFoundException.class, - () -> chatMessageService.deleteChatMessageById(chatMessageId)); - assertTrue(exception.getMessage().contains(chatMessageId.toString())); - assertTrue(exception.getMessage().toLowerCase().contains("not found")); - verify(chatMessageRepository, times(1)).existsById(chatMessageId); - verify(chatMessageRepository, never()).deleteById(chatMessageId); - } - - @Test - void saveChatMessage_ShouldThrowException_WhenActivityNotFound() { - UUID userId = UUID.randomUUID(); - UUID ActivityId = UUID.randomUUID(); - UserDTO userDTO = new UserDTO( - userId, - List.of(), - "johndoe", - "profile.jpg", - "John Doe", - "A bio", - "john.doe@example.com" - ); - ChatMessageDTO chatMessageDTO = new ChatMessageDTO( - UUID.randomUUID(), - "Hello!", - Instant.now(), - userDTO.getId(), - ActivityId, - List.of() - ); - // Stub user exists but Activity not found - User dummyUser = createDummyUser(userDTO.getId()); - when(userRepository.findById(userDTO.getId())).thenReturn(Optional.of(dummyUser)); - when(activityRepository.findById(ActivityId)).thenReturn(Optional.empty()); - assertThrows(Exception.class, - () -> chatMessageService.saveChatMessage(chatMessageDTO)); - verify(chatMessageRepository, never()).save(any(ChatMessage.class)); - } - - @Test - void getAllChatMessages_ShouldReturnChatMessages_WhenMessagesExist() { - ChatMessage chatMessage1 = new ChatMessage(); - ChatMessage chatMessage2 = new ChatMessage(); - UUID id1 = UUID.randomUUID(); - UUID id2 = UUID.randomUUID(); - chatMessage1.setId(id1); - chatMessage1.setContent("Message 1"); - chatMessage2.setId(id2); - chatMessage2.setContent("Message 2"); - // Set a dummy sender on each - User dummyUser = createDummyUser(UUID.randomUUID()); - chatMessage1.setUserSender(dummyUser); - chatMessage2.setUserSender(dummyUser); - // Also set a dummy Activity to avoid NPE in mapping - Activity dummyActivity = createDummyActivity(); - chatMessage1.setActivity(dummyActivity); - chatMessage2.setActivity(dummyActivity); - List messages = List.of(chatMessage1, chatMessage2); - when(chatMessageRepository.findAll()).thenReturn(messages); - when(chatMessageRepository.findById(id1)).thenReturn(Optional.of(chatMessage1)); - when(chatMessageRepository.findById(id2)).thenReturn(Optional.of(chatMessage2)); - when(chatMessageLikesRepository.findByChatMessage(chatMessage1)).thenReturn(new ArrayList<>()); - when(chatMessageLikesRepository.findByChatMessage(chatMessage2)).thenReturn(new ArrayList<>()); - List result = chatMessageService.getAllChatMessages(); - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id1))); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id2))); - } - - @Test - void getAllChatMessages_ShouldThrowBasesNotFoundException_WhenDataAccessExceptionOccurs() { - DataAccessException dae = new DataAccessException("DB error") { - }; - when(chatMessageRepository.findAll()).thenThrow(dae); - assertThrows(BasesNotFoundException.class, () -> chatMessageService.getAllChatMessages()); - verify(logger, times(1)).error(dae.getMessage()); - } - - @Test - void getChatMessageLikeUserIds_ShouldReturnUserIds_WhenLikesExist() { - UUID chatMessageId = UUID.randomUUID(); - ChatMessage chatMessage = new ChatMessage(); - chatMessage.setId(chatMessageId); - // Set a dummy sender (even if not used in this method) - chatMessage.setUserSender(createDummyUser(UUID.randomUUID())); - // Set a dummy Activity to avoid NPE if mapper is used indirectly - chatMessage.setActivity(createDummyActivity()); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(chatMessage)); - ChatMessageLikes like1 = new ChatMessageLikes(); - ChatMessageLikes like2 = new ChatMessageLikes(); - UUID userId1 = UUID.randomUUID(); - UUID userId2 = UUID.randomUUID(); - User user1 = createDummyUser(userId1); - User user2 = createDummyUser(userId2); - like1.setUser(user1); - like2.setUser(user2); - List likes = List.of(like1, like2); - when(chatMessageLikesRepository.findByChatMessage(chatMessage)).thenReturn(likes); - List result = chatMessageService.getChatMessageLikeUserIds(chatMessageId); - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.contains(userId1)); - assertTrue(result.contains(userId2)); - } - - @Test - void getChatMessageLikeUserIds_ShouldThrowBaseNotFoundException_WhenChatMessageNotFound() { - UUID chatMessageId = UUID.randomUUID(); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.empty()); - BaseNotFoundException ex = assertThrows(BaseNotFoundException.class, () -> chatMessageService.getChatMessageLikeUserIds(chatMessageId)); - assertTrue(ex.getMessage().contains(chatMessageId.toString())); - } - - @Test - void getChatMessageById_ShouldReturnChatMessageDTO_WhenMessageExists() { - UUID chatMessageId = UUID.randomUUID(); - ChatMessage chatMessage = new ChatMessage(); - chatMessage.setId(chatMessageId); - chatMessage.setContent("Test message"); - // Set dummy sender and Activity - chatMessage.setUserSender(createDummyUser(UUID.randomUUID())); - chatMessage.setActivity(createDummyActivity()); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(chatMessage)); - when(chatMessageLikesRepository.findByChatMessage(chatMessage)).thenReturn(new ArrayList<>()); - ChatMessageDTO dto = chatMessageService.getChatMessageById(chatMessageId); - assertNotNull(dto); - assertEquals(chatMessageId, dto.getId()); - assertEquals("Test message", dto.getContent()); - } - - @Test - void getChatMessageById_ShouldThrowBaseNotFoundException_WhenMessageNotFound() { - UUID chatMessageId = UUID.randomUUID(); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.empty()); - BaseNotFoundException ex = assertThrows(BaseNotFoundException.class, () -> chatMessageService.getChatMessageById(chatMessageId)); - assertTrue(ex.getMessage().contains(chatMessageId.toString())); - } - - @Test - void getFullChatMessageById_ShouldReturnFullActivityChatMessageDTO_WhenMessageExists() { - UUID chatMessageId = UUID.randomUUID(); - UUID senderId = UUID.randomUUID(); - UUID ActivityId = UUID.randomUUID(); - Instant timestamp = Instant.now(); - ChatMessage chatMessage = new ChatMessage(); - chatMessage.setId(chatMessageId); - chatMessage.setContent("Full message"); - chatMessage.setTimestamp(timestamp); - // Set dummy sender with expected senderId and Activity with expected ActivityId - chatMessage.setUserSender(createDummyUser(senderId)); - Activity dummyActivity = new Activity(); - dummyActivity.setId(ActivityId); - chatMessage.setActivity(dummyActivity); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(chatMessage)); - when(chatMessageLikesRepository.findByChatMessage(chatMessage)).thenReturn(new ArrayList<>()); - BaseUserDTO baseUserDTO = new BaseUserDTO(senderId, "John Doe", "email@example.com", "username", "bio", "avatar.jpg"); - when(userService.getBaseUserById(any(UUID.class))).thenReturn(baseUserDTO); - when(userService.getAllUsers()).thenReturn(new ArrayList<>()); - FullActivityChatMessageDTO result = chatMessageService.getFullChatMessageById(chatMessageId); - assertNotNull(result); - assertEquals(chatMessageId, result.getId()); - assertEquals("Full message", result.getContent()); - assertEquals(baseUserDTO, result.getSenderUser()); - } - - @Test - void getFullChatMessagesByActivityId_ShouldReturnListOfFullActivityChatMessageDTOs() { - UUID ActivityId = UUID.randomUUID(); - ChatMessage chatMessage1 = new ChatMessage(); - ChatMessage chatMessage2 = new ChatMessage(); - UUID id1 = UUID.randomUUID(); - UUID id2 = UUID.randomUUID(); - chatMessage1.setId(id1); - chatMessage1.setContent("Message 1"); - chatMessage1.setTimestamp(Instant.now()); - chatMessage2.setId(id2); - chatMessage2.setContent("Message 2"); - chatMessage2.setTimestamp(Instant.now()); - // Set dummy sender and Activity for both messages - User dummyUser = createDummyUser(UUID.randomUUID()); - chatMessage1.setUserSender(dummyUser); - chatMessage2.setUserSender(dummyUser); - Activity dummyActivity = createDummyActivity(); - chatMessage1.setActivity(dummyActivity); - chatMessage2.setActivity(dummyActivity); - List messages = List.of(chatMessage1, chatMessage2); - when(chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(ActivityId)).thenReturn(messages); - when(chatMessageRepository.findById(id1)).thenReturn(Optional.of(chatMessage1)); - when(chatMessageRepository.findById(id2)).thenReturn(Optional.of(chatMessage2)); - when(chatMessageLikesRepository.findByChatMessage(chatMessage1)).thenReturn(new ArrayList<>()); - when(chatMessageLikesRepository.findByChatMessage(chatMessage2)).thenReturn(new ArrayList<>()); - BaseUserDTO baseUser = new BaseUserDTO(UUID.randomUUID(), "John Doe", "user@example.com", "user", "bio", "avatar.jpg"); - when(userService.getBaseUserById(any(UUID.class))).thenReturn(baseUser); - when(userService.getAllUsers()).thenReturn(new ArrayList<>()); - List result = chatMessageService.getFullChatMessagesByActivityId(ActivityId); - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id1))); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id2))); - } - - @Test - void saveChatMessage_ShouldSaveMessage_WhenValid() { - UUID userId = UUID.randomUUID(); - UUID ActivityId = UUID.randomUUID(); - ChatMessageDTO chatMessageDTO = new ChatMessageDTO( - UUID.randomUUID(), - "Saving message", - Instant.now(), - userId, - ActivityId, - List.of() - ); - // Create dummy user and Activity so the save proceeds - User dummyUser = createDummyUser(userId); - Activity dummyActivity = new Activity(); - dummyActivity.setId(ActivityId); - when(userRepository.findById(userId)).thenReturn(Optional.of(dummyUser)); - when(activityRepository.findById(ActivityId)).thenReturn(Optional.of(dummyActivity)); - ChatMessage dummyChatMessage = new ChatMessage(); - dummyChatMessage.setId(chatMessageDTO.getId()); - dummyChatMessage.setContent(chatMessageDTO.getContent()); - // Set sender and Activity on the saved entity - dummyChatMessage.setUserSender(dummyUser); - dummyChatMessage.setActivity(dummyActivity); - when(chatMessageRepository.save(any(ChatMessage.class))).thenReturn(dummyChatMessage); - ChatMessageDTO savedDTO = chatMessageService.saveChatMessage(chatMessageDTO); - assertNotNull(savedDTO); - assertEquals(chatMessageDTO.getId(), savedDTO.getId()); - assertEquals("Saving message", savedDTO.getContent()); - } - - @Test - void getChatMessageIdsByActivityId_ShouldReturnIds_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - ChatMessage chatMessage1 = new ChatMessage(); - ChatMessage chatMessage2 = new ChatMessage(); - UUID id1 = UUID.randomUUID(); - UUID id2 = UUID.randomUUID(); - chatMessage1.setId(id1); - chatMessage2.setId(id2); - // Set a dummy sender and Activity on each message - User dummyUser = createDummyUser(UUID.randomUUID()); - chatMessage1.setUserSender(dummyUser); - chatMessage2.setUserSender(dummyUser); - Activity ActivityForMessages = createDummyActivity(); - chatMessage1.setActivity(ActivityForMessages); - chatMessage2.setActivity(ActivityForMessages); - List messages = List.of(chatMessage1, chatMessage2); - when(chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(ActivityId)).thenReturn(messages); - List ids = chatMessageService.getChatMessageIdsByActivityId(ActivityId); - assertNotNull(ids); - assertEquals(2, ids.size()); - assertTrue(ids.contains(id1)); - assertTrue(ids.contains(id2)); - } - - @Test - void createChatMessageLike_ShouldReturnChatMessageLikesDTO_WhenLikeIsCreated() { - UUID chatMessageId = UUID.randomUUID(); - UUID userId = UUID.randomUUID(); - when(chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)).thenReturn(false); - ChatMessage dummyChatMessage = new ChatMessage(); - dummyChatMessage.setId(chatMessageId); - // Set a dummy sender and Activity - dummyChatMessage.setUserSender(createDummyUser(UUID.randomUUID())); - dummyChatMessage.setActivity(createDummyActivity()); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(dummyChatMessage)); - User dummyUser = createDummyUser(userId); - when(userRepository.findById(userId)).thenReturn(Optional.of(dummyUser)); - ChatMessageLikes dummyLike = new ChatMessageLikes(); - dummyLike.setChatMessage(dummyChatMessage); - dummyLike.setUser(dummyUser); - when(chatMessageLikesRepository.save(any(ChatMessageLikes.class))).thenReturn(dummyLike); - var result = chatMessageService.createChatMessageLike(chatMessageId, userId); - assertNotNull(result); - } - - @Test - void createChatMessageLike_ShouldThrowEntityAlreadyExistsException_WhenLikeAlreadyExists() { - UUID chatMessageId = UUID.randomUUID(); - UUID userId = UUID.randomUUID(); - when(chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)).thenReturn(true); - // Due to the try-catch, the thrown exception is wrapped as BaseSaveException. - BaseSaveException ex = assertThrows(BaseSaveException.class, - () -> chatMessageService.createChatMessageLike(chatMessageId, userId)); - assertTrue(ex.getMessage().contains(chatMessageId.toString())); - } - - @Test - void getChatMessageLikes_ShouldReturnUserDTOs_WhenLikesExist() { - UUID chatMessageId = UUID.randomUUID(); - ChatMessage dummyChatMessage = new ChatMessage(); - dummyChatMessage.setId(chatMessageId); - // Set a dummy sender and Activity on the chat message - dummyChatMessage.setUserSender(createDummyUser(UUID.randomUUID())); - dummyChatMessage.setActivity(createDummyActivity()); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(dummyChatMessage)); - ChatMessageLikes dummyLike = new ChatMessageLikes(); - User dummyUser = createDummyUser(UUID.randomUUID()); - dummyLike.setUser(dummyUser); - List likes = List.of(dummyLike); - when(chatMessageLikesRepository.findByChatMessage(dummyChatMessage)).thenReturn(likes); - List friendIds = List.of(UUID.randomUUID()); - when(userService.getFriendUserIdsByUserId(dummyUser.getId())).thenReturn(friendIds); - List result = chatMessageService.getChatMessageLikes(chatMessageId); - assertNotNull(result); - assertEquals(1, result.size()); - assertEquals(dummyUser.getId(), result.get(0).getId()); - } - - @Test - void getChatMessageLikes_ShouldThrowBaseNotFoundException_WhenChatMessageNotFound() { - UUID chatMessageId = UUID.randomUUID(); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.empty()); - BaseNotFoundException ex = assertThrows(BaseNotFoundException.class, () -> chatMessageService.getChatMessageLikes(chatMessageId)); - assertTrue(ex.getMessage().contains(chatMessageId.toString())); - } - - @Test - void deleteChatMessageLike_ShouldDeleteLike_WhenLikeExists() { - UUID chatMessageId = UUID.randomUUID(); - UUID userId = UUID.randomUUID(); - when(chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)).thenReturn(true); - assertDoesNotThrow(() -> chatMessageService.deleteChatMessageLike(chatMessageId, userId)); - verify(chatMessageLikesRepository, times(1)).deleteByChatMessage_IdAndUser_Id(chatMessageId, userId); - } - - @Test - void deleteChatMessageLike_ShouldThrowBasesNotFoundException_WhenLikeDoesNotExist() { - UUID chatMessageId = UUID.randomUUID(); - UUID userId = UUID.randomUUID(); - when(chatMessageLikesRepository.existsByChatMessage_IdAndUser_Id(chatMessageId, userId)).thenReturn(false); - // The service wraps the not-found exception into a BaseDeleteException. - BaseDeleteException ex = assertThrows(BaseDeleteException.class, () -> chatMessageService.deleteChatMessageLike(chatMessageId, userId)); - assertTrue(ex.getMessage().toLowerCase().contains("an error occurred while deleting")); - verify(chatMessageLikesRepository, never()).deleteByChatMessage_IdAndUser_Id(chatMessageId, userId); - } - - @Test - void getChatMessagesByActivityId_ShouldReturnChatMessageDTOs_WhenActivityExists() { - UUID ActivityId = UUID.randomUUID(); - ChatMessage chatMessage1 = new ChatMessage(); - ChatMessage chatMessage2 = new ChatMessage(); - UUID id1 = UUID.randomUUID(); - UUID id2 = UUID.randomUUID(); - chatMessage1.setId(id1); - chatMessage1.setContent("Activity message 1"); - chatMessage2.setId(id2); - chatMessage2.setContent("Activity message 2"); - // Set a dummy sender and Activity on both messages - User dummyUser = createDummyUser(UUID.randomUUID()); - chatMessage1.setUserSender(dummyUser); - chatMessage2.setUserSender(dummyUser); - Activity dummyActivity = createDummyActivity(); - chatMessage1.setActivity(dummyActivity); - chatMessage2.setActivity(dummyActivity); - List messages = List.of(chatMessage1, chatMessage2); - when(chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(ActivityId)).thenReturn(messages); - when(chatMessageRepository.findById(id1)).thenReturn(Optional.of(chatMessage1)); - when(chatMessageRepository.findById(id2)).thenReturn(Optional.of(chatMessage2)); - when(chatMessageLikesRepository.findByChatMessage(chatMessage1)).thenReturn(new ArrayList<>()); - when(chatMessageLikesRepository.findByChatMessage(chatMessage2)).thenReturn(new ArrayList<>()); - List result = chatMessageService.getChatMessagesByActivityId(ActivityId); - assertNotNull(result); - assertEquals(2, result.size()); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id1))); - assertTrue(result.stream().anyMatch(dto -> dto.getId().equals(id2))); - } - - @Test - void getChatMessagesByActivityId_ShouldThrowBasesNotFoundException_WhenDataAccessExceptionOccurs() { - UUID ActivityId = UUID.randomUUID(); - DataAccessException dae = new DataAccessException("DB error") { - }; - when(chatMessageRepository.getChatMessagesByActivityIdOrderByTimestampDesc(ActivityId)).thenThrow(dae); - assertThrows(BasesNotFoundException.class, () -> chatMessageService.getChatMessagesByActivityId(ActivityId)); - verify(logger, times(1)).error(dae.getMessage()); - } - - @Test - void getFullChatMessageByChatMessage_ShouldReturnFullActivityChatMessageDTO() { - UUID chatMessageId = UUID.randomUUID(); - UUID senderId = UUID.randomUUID(); - UUID ActivityId = UUID.randomUUID(); - Instant timestamp = Instant.now(); - ChatMessageDTO chatMessageDTO = new ChatMessageDTO( - chatMessageId, - "Full chat message", - timestamp, - senderId, - ActivityId, - List.of() - ); - ChatMessage dummyChatMessage = new ChatMessage(); - dummyChatMessage.setId(chatMessageId); - // Set dummy sender with the same senderId and Activity with the same ActivityId - dummyChatMessage.setUserSender(createDummyUser(senderId)); - Activity dummyActivity = new Activity(); - dummyActivity.setId(ActivityId); - dummyChatMessage.setActivity(dummyActivity); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(dummyChatMessage)); - when(chatMessageLikesRepository.findByChatMessage(dummyChatMessage)).thenReturn(new ArrayList<>()); - BaseUserDTO baseUserDTO = new BaseUserDTO(senderId, "John Doe", "email@example.com", "username", "bio", "avatar.jpg"); - when(userService.getBaseUserById(senderId)).thenReturn(baseUserDTO); - when(userService.getAllUsers()).thenReturn(new ArrayList<>()); - FullActivityChatMessageDTO fullDto = chatMessageService.getFullChatMessageByChatMessage(chatMessageDTO); - assertNotNull(fullDto); - assertEquals(chatMessageId, fullDto.getId()); - assertEquals("Full chat message", fullDto.getContent()); - assertEquals(timestamp, fullDto.getTimestamp()); - assertEquals(ActivityId, fullDto.getActivityId()); - assertEquals(baseUserDTO, fullDto.getSenderUser()); - } - - @Test - void convertChatMessagesToFullFeedActivityChatMessages_ShouldReturnConvertedList() { - UUID chatMessageId = UUID.randomUUID(); - UUID senderId = UUID.randomUUID(); - UUID ActivityId = UUID.randomUUID(); - Instant timestamp = Instant.now(); - ChatMessageDTO chatMessageDTO = new ChatMessageDTO( - chatMessageId, - "Chat message conversion", - timestamp, - senderId, - ActivityId, - List.of() - ); - List chatMessageDTOs = List.of(chatMessageDTO); - ChatMessage dummyChatMessage = new ChatMessage(); - dummyChatMessage.setId(chatMessageId); - // Set dummy sender and Activity on the entity - dummyChatMessage.setUserSender(createDummyUser(senderId)); - Activity dummyActivity = new Activity(); - dummyActivity.setId(ActivityId); - dummyChatMessage.setActivity(dummyActivity); - when(chatMessageRepository.findById(chatMessageId)).thenReturn(Optional.of(dummyChatMessage)); - when(chatMessageLikesRepository.findByChatMessage(dummyChatMessage)).thenReturn(new ArrayList<>()); - BaseUserDTO baseUserDTO = new BaseUserDTO(senderId, "John Doe", "email@example.com", "username", "bio", "avatar.jpg"); - when(userService.getBaseUserById(senderId)).thenReturn(baseUserDTO); - when(userService.getAllUsers()).thenReturn(new ArrayList<>()); - List result = chatMessageService.convertChatMessagesToFullFeedActivityChatMessages(chatMessageDTOs); - assertNotNull(result); - assertEquals(1, result.size()); - FullActivityChatMessageDTO fullDto = result.get(0); - assertEquals(chatMessageId, fullDto.getId()); - assertEquals("Chat message conversion", fullDto.getContent()); - assertEquals(timestamp, fullDto.getTimestamp()); - assertEquals(ActivityId, fullDto.getActivityId()); - assertEquals(baseUserDTO, fullDto.getSenderUser()); - } -} diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/UserSearchServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/UserSearchServiceTests.java index 9bfeb15ce..565e7c5ca 100644 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/UserSearchServiceTests.java +++ b/src/test/java/com/danielagapov/spawn/ServiceTests/UserSearchServiceTests.java @@ -9,7 +9,7 @@ import com.danielagapov.spawn.shared.util.UserStatus; import com.danielagapov.spawn.shared.exceptions.Logger.ILogger; import com.danielagapov.spawn.user.internal.domain.User; -import com.danielagapov.spawn.activity.internal.repositories.IActivityUserRepository; +import com.danielagapov.spawn.shared.feign.ActivityServiceClient; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; import com.danielagapov.spawn.analytics.internal.services.SearchAnalyticsService; import com.danielagapov.spawn.social.internal.services.IBlockedUserService; @@ -43,7 +43,7 @@ class UserSearchServiceTests { private IUserRepository userRepository; // Mock repository @Mock - private IActivityUserRepository activityUserRepository; + private ActivityServiceClient activityServiceClient; @Mock private IFriendRequestService friendRequestService; diff --git a/src/test/java/com/danielagapov/spawn/ServiceTests/UserServiceTests.java b/src/test/java/com/danielagapov/spawn/ServiceTests/UserServiceTests.java index 187df0f55..38271c0a2 100644 --- a/src/test/java/com/danielagapov/spawn/ServiceTests/UserServiceTests.java +++ b/src/test/java/com/danielagapov/spawn/ServiceTests/UserServiceTests.java @@ -11,7 +11,6 @@ import com.danielagapov.spawn.social.internal.repositories.IFriendshipRepository; import com.danielagapov.spawn.user.internal.repositories.IUserRepository; import com.danielagapov.spawn.auth.internal.repositories.IUserIdExternalIdMapRepository; -import com.danielagapov.spawn.activity.internal.services.IActivityTypeService; import com.danielagapov.spawn.social.internal.services.IBlockedUserService; import com.danielagapov.spawn.social.internal.services.IFriendRequestService; import com.danielagapov.spawn.media.internal.services.IS3Service; @@ -63,9 +62,6 @@ public class UserServiceTests { @Mock private CacheManager cacheManager; - @Mock - private IActivityTypeService activityTypeService; - @Mock private IUserIdExternalIdMapRepository userIdExternalIdMapRepository;