diff --git a/.env b/.env
index 9c1e6f17..26c1f02f 100644
--- a/.env
+++ b/.env
@@ -21,6 +21,9 @@ DOCKER_GID=999
AI_USERNAME=postgres_ai
AI_DB_PASSWORD=dinhanst2832004
AI_DATABASE=ai_db
+POST_USERNAME=postgres_post
+POST_DB_PASSWORD=dinhanst2832004
+POST_DATABASE=post_db
#Redis
REDIS_PASSWORD=dinhanst2832004
#Neo4j
diff --git a/.idea/compiler.xml b/.idea/compiler.xml
index c9d19361..0adf030b 100644
--- a/.idea/compiler.xml
+++ b/.idea/compiler.xml
@@ -18,6 +18,15 @@
+
+
+
+
+
+
+
+
+
@@ -31,6 +40,7 @@
+
@@ -58,7 +68,9 @@
+
+
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
index a715e768..4895500e 100644
--- a/.idea/encodings.xml
+++ b/.idea/encodings.xml
@@ -10,6 +10,8 @@
+
+
diff --git a/.vs/VSWorkspaceState.json b/.vs/VSWorkspaceState.json
new file mode 100644
index 00000000..1df61109
--- /dev/null
+++ b/.vs/VSWorkspaceState.json
@@ -0,0 +1,9 @@
+{
+ "ExpandedNodes": [
+ "",
+ "\\FileService",
+ "\\FileService\\FileService.Api"
+ ],
+ "SelectedNode": "\\FileService\\FileService.sln",
+ "PreviewInSolutionExplorer": false
+}
\ No newline at end of file
diff --git a/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx b/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx
new file mode 100644
index 00000000..06265279
Binary files /dev/null and b/.vs/backend/FileContentIndex/4a76a1dd-5482-471a-9378-46b89c11dd52.vsidx differ
diff --git a/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx b/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx
new file mode 100644
index 00000000..898b42aa
Binary files /dev/null and b/.vs/backend/FileContentIndex/b3582c71-e689-4300-a0e2-16d2db0ab231.vsidx differ
diff --git a/.vs/backend/v17/.wsuo b/.vs/backend/v17/.wsuo
new file mode 100644
index 00000000..fba11942
Binary files /dev/null and b/.vs/backend/v17/.wsuo differ
diff --git a/.vs/backend/v17/DocumentLayout.backup.json b/.vs/backend/v17/DocumentLayout.backup.json
new file mode 100644
index 00000000..133085a6
--- /dev/null
+++ b/.vs/backend/v17/DocumentLayout.backup.json
@@ -0,0 +1,37 @@
+{
+ "Version": 1,
+ "WorkspaceRootPath": "D:\\CNTT\\Capstone Project\\backend\\",
+ "Documents": [
+ {
+ "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ }
+ ],
+ "DocumentGroupContainers": [
+ {
+ "Orientation": 0,
+ "VerticalTabListWidth": 256,
+ "DocumentGroups": [
+ {
+ "DockedWidth": 200,
+ "SelectedChildIndex": 0,
+ "Children": [
+ {
+ "$type": "Document",
+ "DocumentIndex": 0,
+ "Title": "Program.cs",
+ "DocumentMoniker": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs",
+ "RelativeDocumentMoniker": "FileService\\FileService.Api\\Program.cs",
+ "ToolTip": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs",
+ "RelativeToolTip": "FileService\\FileService.Api\\Program.cs",
+ "ViewState": "AgIAAAkAAAAAAAAAAAAAABMAAAAoAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-08-12T20:18:19.009Z",
+ "EditorCaption": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vs/backend/v17/DocumentLayout.json b/.vs/backend/v17/DocumentLayout.json
new file mode 100644
index 00000000..133085a6
--- /dev/null
+++ b/.vs/backend/v17/DocumentLayout.json
@@ -0,0 +1,37 @@
+{
+ "Version": 1,
+ "WorkspaceRootPath": "D:\\CNTT\\Capstone Project\\backend\\",
+ "Documents": [
+ {
+ "AbsoluteMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}",
+ "RelativeMoniker": "D:0:0:{A2FE74E1-B743-11D0-AE1A-00A0C90FFFC3}|\u003CMiscFiles\u003E|solutionrelative:FileService\\FileService.Api\\Program.cs||{A6C744A8-0E4A-4FC6-886A-064283054674}"
+ }
+ ],
+ "DocumentGroupContainers": [
+ {
+ "Orientation": 0,
+ "VerticalTabListWidth": 256,
+ "DocumentGroups": [
+ {
+ "DockedWidth": 200,
+ "SelectedChildIndex": 0,
+ "Children": [
+ {
+ "$type": "Document",
+ "DocumentIndex": 0,
+ "Title": "Program.cs",
+ "DocumentMoniker": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs",
+ "RelativeDocumentMoniker": "FileService\\FileService.Api\\Program.cs",
+ "ToolTip": "D:\\CNTT\\Capstone Project\\backend\\FileService\\FileService.Api\\Program.cs",
+ "RelativeToolTip": "FileService\\FileService.Api\\Program.cs",
+ "ViewState": "AgIAAAkAAAAAAAAAAAAAABMAAAAoAAAAAAAAAA==",
+ "Icon": "ae27a6b0-e345-4288-96df-5eaf394ee369.000738|",
+ "WhenOpened": "2025-08-12T20:18:19.009Z",
+ "EditorCaption": ""
+ }
+ ]
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/.vs/backend/v17/workspaceFileList.bin b/.vs/backend/v17/workspaceFileList.bin
new file mode 100644
index 00000000..4f8d79fb
Binary files /dev/null and b/.vs/backend/v17/workspaceFileList.bin differ
diff --git a/.vs/slnx.sqlite b/.vs/slnx.sqlite
new file mode 100644
index 00000000..c2dedd8c
Binary files /dev/null and b/.vs/slnx.sqlite differ
diff --git a/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java b/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java
index 85a31f5f..6f595a88 100644
--- a/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java
+++ b/coding-service/src/main/java/com/codecampus/coding/exception/ErrorCode.java
@@ -48,6 +48,8 @@ public enum ErrorCode {
USER_ALREADY_EXISTS(4098101, CONFLICT_STATUS, "Người dùng đã tồn tại!",
CONFLICT),
+ // 410
+
;
private final int code;
diff --git a/docker-compose.prod-infra.yml b/docker-compose.prod-infra.yml
index fa406fad..2ccfd6e5 100644
--- a/docker-compose.prod-infra.yml
+++ b/docker-compose.prod-infra.yml
@@ -22,6 +22,26 @@ services:
retries: 5
networks: [ backend ]
+ post-db:
+ image: bitnami/postgresql:latest
+ container_name: post-db
+ restart: unless-stopped
+ environment:
+ - POSTGRESQL_USERNAME=${POST_USERNAME}
+ - POSTGRESQL_PASSWORD=${POST_DB_PASSWORD}
+ - POSTGRESQL_DATABASE=${POST_DATABASE}
+ - POSTGRESQL_POSTGRES_PASSWORD=${POST_DB_PASSWORD}
+ ports:
+ - "5439:5432"
+# volumes:
+# - post_pg_data:/bitnami/postgresql
+ healthcheck:
+ test: [ "CMD-SHELL","pg_isready -U postgres" ]
+ interval: 10s
+ timeout: 5s
+ retries: 5
+ networks: [ backend ]
+
quiz-db:
image: bitnami/postgresql:latest
container_name: quiz-db
diff --git a/pom.xml b/pom.xml
index 7a2818d6..e2480204 100644
--- a/pom.xml
+++ b/pom.xml
@@ -31,6 +31,7 @@
search-serviceai-servicechat-service
+ post-service
diff --git a/post-service/.gitattributes b/post-service/.gitattributes
new file mode 100644
index 00000000..3b41682a
--- /dev/null
+++ b/post-service/.gitattributes
@@ -0,0 +1,2 @@
+/mvnw text eol=lf
+*.cmd text eol=crlf
diff --git a/post-service/.gitignore b/post-service/.gitignore
new file mode 100644
index 00000000..667aaef0
--- /dev/null
+++ b/post-service/.gitignore
@@ -0,0 +1,33 @@
+HELP.md
+target/
+.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### STS ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### IntelliJ IDEA ###
+.idea
+*.iws
+*.iml
+*.ipr
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
diff --git a/post-service/.mvn/wrapper/maven-wrapper.properties b/post-service/.mvn/wrapper/maven-wrapper.properties
new file mode 100644
index 00000000..12fbe1e9
--- /dev/null
+++ b/post-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.11/apache-maven-3.9.11-bin.zip
diff --git a/post-service/mvnw b/post-service/mvnw
new file mode 100644
index 00000000..19529ddf
--- /dev/null
+++ b/post-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/post-service/mvnw.cmd b/post-service/mvnw.cmd
new file mode 100644
index 00000000..249bdf38
--- /dev/null
+++ b/post-service/mvnw.cmd
@@ -0,0 +1,149 @@
+<# : batch portion
+@REM ----------------------------------------------------------------------------
+@REM Licensed to the Apache Software Foundation (ASF) under one
+@REM or more contributor license agreements. See the NOTICE file
+@REM distributed with this work for additional information
+@REM regarding copyright ownership. The ASF licenses this file
+@REM to you under the Apache License, Version 2.0 (the
+@REM "License"); you may not use this file except in compliance
+@REM with the License. You may obtain a copy of the License at
+@REM
+@REM http://www.apache.org/licenses/LICENSE-2.0
+@REM
+@REM Unless required by applicable law or agreed to in writing,
+@REM software distributed under the License is distributed on an
+@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+@REM KIND, either express or implied. See the License for the
+@REM specific language governing permissions and limitations
+@REM under the License.
+@REM ----------------------------------------------------------------------------
+
+@REM ----------------------------------------------------------------------------
+@REM Apache Maven Wrapper startup batch script, version 3.3.2
+@REM
+@REM Optional ENV vars
+@REM MVNW_REPOURL - repo url base for downloading maven distribution
+@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
+@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
+@REM ----------------------------------------------------------------------------
+
+@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
+@SET __MVNW_CMD__=
+@SET __MVNW_ERROR__=
+@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
+@SET PSModulePath=
+@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
+ IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
+)
+@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
+@SET __MVNW_PSMODULEP_SAVE=
+@SET __MVNW_ARG0_NAME__=
+@SET MVNW_USERNAME=
+@SET MVNW_PASSWORD=
+@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*)
+@echo Cannot start maven from wrapper >&2 && exit /b 1
+@GOTO :EOF
+: end batch / begin powershell #>
+
+$ErrorActionPreference = "Stop"
+if ($env:MVNW_VERBOSE -eq "true") {
+ $VerbosePreference = "Continue"
+}
+
+# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
+$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
+if (!$distributionUrl) {
+ Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
+}
+
+switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
+ "maven-mvnd-*" {
+ $USE_MVND = $true
+ $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
+ $MVN_CMD = "mvnd.cmd"
+ break
+ }
+ default {
+ $USE_MVND = $false
+ $MVN_CMD = $script -replace '^mvnw','mvn'
+ break
+ }
+}
+
+# apply MVNW_REPOURL and calculate MAVEN_HOME
+# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/
+if ($env:MVNW_REPOURL) {
+ $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" }
+ $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')"
+}
+$distributionUrlName = $distributionUrl -replace '^.*/',''
+$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
+$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain"
+if ($env:MAVEN_USER_HOME) {
+ $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain"
+}
+$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
+$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
+
+if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
+ Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
+ Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+ exit $?
+}
+
+if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
+ Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
+}
+
+# prepare tmp dir
+$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
+$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
+$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
+trap {
+ if ($TMP_DOWNLOAD_DIR.Exists) {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+ }
+}
+
+New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
+
+# Download and Install Apache Maven
+Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
+Write-Verbose "Downloading from: $distributionUrl"
+Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
+
+$webclient = New-Object System.Net.WebClient
+if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
+ $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
+}
+[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
+$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
+
+# If specified, validate the SHA-256 sum of the Maven distribution zip file
+$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
+if ($distributionSha256Sum) {
+ if ($USE_MVND) {
+ Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
+ }
+ Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
+ if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
+ Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
+ }
+}
+
+# unzip and move
+Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
+Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null
+try {
+ Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
+} catch {
+ if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
+ Write-Error "fail to move MAVEN_HOME"
+ }
+} finally {
+ try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
+ catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
+}
+
+Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
diff --git a/post-service/pom.xml b/post-service/pom.xml
new file mode 100644
index 00000000..5300f25b
--- /dev/null
+++ b/post-service/pom.xml
@@ -0,0 +1,337 @@
+
+
+ 4.0.0
+
+ com.codecampus
+ codecampus
+ 0.0.1-SNAPSHOT
+ ../pom.xml
+
+
+ post-service
+ post
+ Post Service Repository
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 21
+ UTF-8
+ ${java.version}
+ ${java.version}
+
+
+ 1.72.0
+ 4.30.2
+ 2024.0.1
+
+ 1.18.38
+ 1.6.3
+ 0.2.0
+
+ 2.19.0
+ 10.3
+ 6.5.0
+ 0.8.13
+ 3.1.0.RELEASE
+
+
+ 0.6.1
+ 1.7.1
+ 3.12.1
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-websocket
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ org.springframework.boot
+ spring-boot-starter-mail
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-oauth2-resource-server
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-starter-openfeign
+
+
+ io.github.openfeign.form
+ feign-form
+ 3.8.0
+
+
+ io.github.openfeign.form
+ feign-form-spring
+ 3.8.0
+
+
+ org.springframework.kafka
+ spring-kafka
+
+
+
+
+ net.devh
+ grpc-server-spring-boot-starter
+ ${grpc.server.starter.version}
+
+
+ io.grpc
+ grpc-services
+ ${grpc.version}
+
+
+ io.grpc
+ grpc-stub
+ ${grpc.version}
+
+
+ io.grpc
+ grpc-netty-shaded
+ ${grpc.version}
+
+
+ io.grpc
+ grpc-protobuf
+ ${grpc.version}
+
+
+ io.grpc
+ grpc-census
+ ${grpc.version}
+
+
+ com.google.protobuf
+ protobuf-java
+ ${protobuf.version}
+
+
+ com.codecampus
+ common-protos
+ ${project.version}
+
+
+
+
+ org.mapstruct
+ mapstruct
+ ${mapstruct.version}
+
+
+ com.fasterxml.jackson.datatype
+ jackson-datatype-jsr310
+ ${jackson.version}
+
+
+
+
+ com.nimbusds
+ nimbus-jose-jwt
+ ${nimbus-jose.version}
+
+
+ org.springframework.security
+ spring-security-oauth2-jose
+ ${security-oauth2-jose.version}
+
+
+
+
+ org.springframework.boot
+ spring-boot-devtools
+ runtime
+ true
+
+
+ com.h2database
+ h2
+ runtime
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.grpc
+ spring-grpc-test
+ test
+
+
+ org.springframework.kafka
+ spring-kafka-test
+ test
+
+
+ org.springframework.security
+ spring-security-test
+ test
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+ provided
+
+
+
+
+
+
+ org.springframework.cloud
+ spring-cloud-dependencies
+ ${spring-cloud.version}
+ pom
+ import
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ ${maven.compiler.plugin.version}
+
+ ${java.version}
+ ${java.version}
+
+
+ org.projectlombok
+ lombok
+ ${lombok.version}
+
+
+ org.projectlombok
+ lombok-mapstruct-binding
+ ${lombok.mapstruct.binding.version}
+
+
+ org.mapstruct
+ mapstruct-processor
+ ${mapstruct.version}
+
+
+
+ --enable-preview
+ -Amapstruct.defaultComponentModel=spring
+ -Amapstruct.verbose=true
+
+
+
+
+
+
+ org.jacoco
+ jacoco-maven-plugin
+ ${jacoco.version}
+
+
+
+ prepare-agent
+
+
+
+ report
+ prepare-package
+
+ report
+
+
+
+
+
+ com/codecampus/post/dto/**
+ com/codecampus/post/entity/**
+ com/codecampus/post/mapper/**
+ com/codecampus/post/configuration/**
+
+
+
+
+
+
+ kr.motd.maven
+ os-maven-plugin
+ ${os.maven.plugin.version}
+
+
+ detect
+ initialize
+
+ detect
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
diff --git a/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java b/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java
new file mode 100644
index 00000000..e05bdbc8
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/Mapper/PostMapper.java
@@ -0,0 +1,23 @@
+package com.codecampus.post.Mapper;
+
+
+import com.codecampus.post.dto.request.PostRequestDto;
+import com.codecampus.post.entity.Post;
+import org.mapstruct.BeanMapping;
+import org.mapstruct.Mapper;
+import org.mapstruct.MappingTarget;
+import org.mapstruct.NullValuePropertyMappingStrategy;
+
+import java.util.List;
+
+@Mapper(componentModel = "spring")
+public interface PostMapper {
+ PostRequestDto toDto(Post post);
+ List toDtoList(List posts);
+
+ @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
+ Post toEntity(PostRequestDto postRequestDto);
+
+ @BeanMapping(nullValuePropertyMappingStrategy = NullValuePropertyMappingStrategy.IGNORE)
+ void updateEntityFromDto(PostRequestDto postRequestDto, @MappingTarget Post post);
+}
diff --git a/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java b/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java
new file mode 100644
index 00000000..33095ed9
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/PostServiceApplication.java
@@ -0,0 +1,15 @@
+package com.codecampus.post;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+
+@SpringBootApplication
+@EnableFeignClients(basePackages = "com.codecampus.post.service.FeignConfig")
+public class PostServiceApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(PostServiceApplication.class, args);
+ }
+
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java b/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java
new file mode 100644
index 00000000..0b6b6536
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/AuthenticationRequestInterceptor.java
@@ -0,0 +1,44 @@
+package com.codecampus.post.config;
+
+import feign.RequestInterceptor;
+import feign.RequestTemplate;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.util.StringUtils;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+/**
+ * Feign RequestInterceptor để tự động thêm header Authorization
+ * của HTTP request hiện tại vào mọi request gửi đi qua Feign client.
+ *
+ *
Khi có ServletRequestAttributes từ RequestContextHolder,
+ * lấy header "Authorization" từ HttpServletRequest và
+ * thêm vào RequestTemplate; nếu header rỗng, bỏ qua.
+ */
+@Slf4j
+public class AuthenticationRequestInterceptor
+ implements RequestInterceptor {
+ /**
+ * Phương thức được gọi trước khi Feign gửi request.
+ *
+ *
+ *
Lấy ServletRequestAttributes từ RequestContextHolder.
+ *
Nếu tồn tại, lấy HttpServletRequest và trích header "Authorization".
+ *
Nếu header có giá trị, thêm vào RequestTemplate của Feign.
+ *
+ *
+ * @param requestTemplate template của request chuẩn bị gửi đi
+ */
+ @Override
+ public void apply(RequestTemplate requestTemplate) {
+ ServletRequestAttributes attributes =
+ (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+
+ var authHeader = attributes.getRequest().getHeader("Authorization");
+
+ log.info("Header: {}", authHeader);
+ if (StringUtils.hasText(authHeader)) {
+ requestTemplate.header("Authorization", authHeader);
+ }
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java b/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java
new file mode 100644
index 00000000..c29afefc
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/CustomJwtDecoder.java
@@ -0,0 +1,50 @@
+package com.codecampus.post.config;
+
+import com.nimbusds.jwt.SignedJWT;
+import org.springframework.security.oauth2.jwt.Jwt;
+import org.springframework.security.oauth2.jwt.JwtDecoder;
+import org.springframework.security.oauth2.jwt.JwtException;
+import org.springframework.stereotype.Component;
+
+import java.text.ParseException;
+
+/**
+ * JwtDecoder tùy chỉnh sử dụng thư viện Nimbus để phân tích và giải mã JWT.
+ *
+ *
Phương thức {@link #decode} sẽ:
+ *
+ *
Parse chuỗi token thành đối tượng SignedJWT.
+ *
Trích xuất thời gian phát hành (issueTime) và thời gian hết hạn (expirationTime)
+ * dưới dạng Instant.
+ *
Tạo đối tượng {@link Jwt} của Spring Security với các thông tin:
+ * token gốc, lần phát hành, thời gian hết hạn, header và claims của JWT.
+ *
Ném {@link JwtException} nếu không thể parse hoặc token không hợp lệ.
+ *
+ *
+ */
+@Component
+public class CustomJwtDecoder implements JwtDecoder {
+ /**
+ * Giải mã chuỗi JWT và trả về đối tượng Jwt chứa thông tin claims.
+ *
+ * @param token chuỗi JWT đã được ký (không bao gồm tiền tố Bearer)
+ * @return đối tượng Jwt chứa token gốc, thời gian phát hành, hết hạn, header và claims
+ * @throws JwtException nếu token không hợp lệ hoặc không parse được
+ */
+ @Override
+ public Jwt decode(String token) throws JwtException {
+ try {
+ SignedJWT signedJWT = SignedJWT.parse(token);
+
+ return new Jwt(
+ token,
+ signedJWT.getJWTClaimsSet().getIssueTime().toInstant(),
+ signedJWT.getJWTClaimsSet().getExpirationTime().toInstant(),
+ signedJWT.getHeader().toJSONObject(),
+ signedJWT.getJWTClaimsSet().getClaims()
+ );
+ } catch (ParseException e) {
+ throw new JwtException("Invalid token");
+ }
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java b/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java
new file mode 100644
index 00000000..c1b786ce
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/DualPortConfig.java
@@ -0,0 +1,29 @@
+//package com.codecampus.coding.config;
+//
+//import org.apache.catalina.connector.Connector;
+//import org.springframework.beans.factory.annotation.Value;
+//import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
+//import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
+//import org.springframework.context.annotation.Bean;
+//import org.springframework.context.annotation.Configuration;
+//
+//@Configuration
+//public class DualPortConfig {
+// @Value("${server.http.port}")
+// private int httpPort;
+//
+// @Bean
+// public ServletWebServerFactory servletContainer() {
+// // Tomcat là default container của Spring Boot Starter Web
+// TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory();
+//
+// Connector httpConnector =
+// new Connector(TomcatServletWebServerFactory.DEFAULT_PROTOCOL);
+// httpConnector.setScheme("http");
+// httpConnector.setPort(httpPort);
+// httpConnector.setSecure(false);
+//
+// factory.addAdditionalTomcatConnectors(httpConnector);
+// return factory;
+// }
+//}
diff --git a/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java b/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java
new file mode 100644
index 00000000..e2513323
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/FeignConfiguration.java
@@ -0,0 +1,28 @@
+package com.codecampus.post.config;
+
+import feign.codec.Encoder;
+import feign.form.spring.SpringFormEncoder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Cấu hình cho Feign client để hỗ trợ gửi dữ liệu dạng multipart/form-data.
+ *
+ *
Bean {@link Encoder} được đăng ký sử dụng
+ * {@link SpringFormEncoder} giúp Feign mã hóa các request
+ * có nội dung form hoặc file upload một cách tự động.
+ *
+ */
+@Configuration
+public class FeignConfiguration {
+ /**
+ * Đăng ký {@link Encoder} tùy chỉnh cho Feign,
+ * sử dụng {@link SpringFormEncoder} để mã hóa multipart/form-data.
+ *
+ * @return đối tượng Encoder hỗ trợ form encoding
+ */
+ @Bean
+ public Encoder multipartFormEncoder() {
+ return new SpringFormEncoder();
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java b/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java
new file mode 100644
index 00000000..64bfe13e
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/JwtAuthenticationEntryPoint.java
@@ -0,0 +1,66 @@
+package com.codecampus.post.config;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.codecampus.post.exception.ErrorCode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.http.MediaType;
+import org.springframework.security.core.AuthenticationException;
+import org.springframework.security.web.AuthenticationEntryPoint;
+
+import java.io.IOException;
+
+/**
+ * Entry point tùy chỉnh cho Spring Security khi phát hiện yêu cầu không được xác thực.
+ *
+ *
Khi quá trình xác thực thất bại (ví dụ thiếu token hoặc token không hợp lệ),
+ * phương thức {@link #commence} sẽ được gọi để trả về phản hồi HTTP 401 với
+ * payload JSON chứa mã lỗi và thông điệp tương ứng.
+ */
+public class JwtAuthenticationEntryPoint
+ implements AuthenticationEntryPoint {
+ /**
+ * Xử lý phản hồi khi yêu cầu không được xác thực.
+ *
+ *
Thiết lập:
+ *
+ *
Status HTTP = 401 (Unauthorized).
+ *
Content-Type = application/json.
+ *
Body JSON bao gồm:
+ *
+ *
code: mã lỗi từ {@link ErrorCode#UNAUTHENTICATED}.
Cho phép truy cập PUBLIC_ENDPOINTS không cần xác thực.
+ *
Các request khác phải xác thực JWT.
+ *
Sử dụng customJwtDecoder và jwtAuthenticationConverter để xử lý JWT.
+ *
Sử dụng JwtAuthenticationEntryPoint để trả về 401 khi xác thực thất bại.
+ *
Tắt CSRF.
+ *
+ *
+ *
+ * @param httpSecurity đối tượng HttpSecurity để cấu hình
+ * @return SecurityFilterChain đã cấu hình
+ * @throws Exception nếu cấu hình gặp lỗi
+ */
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity httpSecurity) throws Exception {
+ httpSecurity.authorizeHttpRequests(request -> request
+ .requestMatchers(PUBLIC_ENDPOINTS)
+ .permitAll()
+ .anyRequest()
+ .authenticated());
+
+ httpSecurity.oauth2ResourceServer(
+ oauth2 -> oauth2.jwt(jwtConfigurer -> jwtConfigurer
+ .decoder(customJwtDecoder)
+ .jwtAuthenticationConverter(
+ jwtAuthenticationConverter()))
+ .authenticationEntryPoint(
+ new JwtAuthenticationEntryPoint()));
+ httpSecurity.csrf(AbstractHttpConfigurer::disable);
+
+ return httpSecurity.build();
+ }
+
+ /**
+ * Cấu hình CORS cho ứng dụng.
+ *
+ * Phương thức này thiết lập:
+ *
+ *
Các origin được phép truy cập (danh sách FRONTEND_ENDPOINT, FRONTEND_ENDPOINT2, FRONTEND_ENDPOINT3).
+ *
Các phương thức HTTP được phép (GET, POST, PUT, DELETE, PATCH, OPTIONS).
+ *
Cho phép tất cả các header.
+ *
Cho phép gửi credentials.
+ *
Các header được expose (ví dụ: "Authorization").
+ *
+ * Áp dụng cấu hình này cho tất cả các endpoint.
+ *
+ *
+ * @return CorsConfigurationSource chứa cấu hình CORS của ứng dụng
+ */
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource() {
+ CorsConfiguration configuration = new CorsConfiguration();
+
+// // Cho phép các origin truy cập định nghĩa sẵn
+// configuration.setAllowedOrigins(
+// List.of(FRONTEND_ENDPOINT, FRONTEND_ENDPOINT2,
+// FRONTEND_ENDPOINT3)
+// );
+
+ // Cho phép các phương thức HTTP được định nghĩa
+ configuration.setAllowedMethods(
+ List.of(GET_METHOD, POST_METHOD, PUT_METHOD, DELETE_METHOD,
+ PATCH_METHOD, OPTIONS_METHOD));
+
+ // Cho phép tất cả các header
+ configuration.setAllowedHeaders(
+ List.of(AUTHORIZATION_HEADER, CONTENT_TYPE_HEADER,
+ ACCEPT_HEADER));
+
+ // Cho phép gửi credentials (cookie, header, v.v.)
+ configuration.setAllowCredentials(true);
+
+ // Expose header "Authorization"
+ configuration.setExposedHeaders(List.of(AUTHORIZATION_HEADER));
+
+ // Thời gian cache preflight request
+ configuration.setMaxAge(3600L);
+
+ UrlBasedCorsConfigurationSource source =
+ new UrlBasedCorsConfigurationSource();
+
+ // Áp dụng cấu hình cho tất cả các endpoint
+ source.registerCorsConfiguration(URL_PATTERN_ALL, configuration);
+ return source;
+ }
+
+ /**
+ * Tạo {@link JwtAuthenticationConverter} để chuyển đổi JWT claims thành GrantedAuthority.
+ * Loại bỏ prefix mặc định (ví dụ "SCOPE_").
+ *
+ * @return JwtAuthenticationConverter đã cấu hình
+ */
+ @Bean
+ JwtAuthenticationConverter jwtAuthenticationConverter() {
+ JwtGrantedAuthoritiesConverter authConverter =
+ new JwtGrantedAuthoritiesConverter();
+ authConverter.setAuthorityPrefix("");
+
+ JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
+ converter.setJwtGrantedAuthoritiesConverter(authConverter);
+
+ // Principal = claim "username"
+ converter.setPrincipalClaimName("username");
+
+ return converter;
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java b/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java
new file mode 100644
index 00000000..bfba3045
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/audit/JpaAuditConfig.java
@@ -0,0 +1,31 @@
+package com.codecampus.post.config.audit;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.data.domain.AuditorAware;
+import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.util.Optional;
+
+@Configuration
+@EnableJpaAuditing(auditorAwareRef = "auditorProvider")
+public class JpaAuditConfig {
+ /**
+ * Trả về username hiện tại (hoặc “system” nếu chưa đăng nhập).
+ * Spring Data sẽ dùng giá trị này để gán vào @CreatedBy / @LastModifiedBy.
+ */
+ @Bean
+ public AuditorAware auditorProvider() {
+ return () -> {
+ Authentication auth = SecurityContextHolder
+ .getContext()
+ .getAuthentication();
+
+ return Optional.ofNullable(auth)
+ .map(Authentication::getName)
+ .or(() -> Optional.of("system"));
+ };
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java b/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java
new file mode 100644
index 00000000..760d06a1
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/config/audit/SoftDeleteInterceptor.java
@@ -0,0 +1,38 @@
+package com.codecampus.post.config.audit;
+
+import com.codecampus.post.entity.audit.SoftDeletable;
+import lombok.extern.slf4j.Slf4j;
+import org.hibernate.EmptyInterceptor;
+import org.hibernate.type.Type;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+import java.time.Instant;
+
+@Slf4j
+public class SoftDeleteInterceptor extends EmptyInterceptor {
+ @Override
+ public void onDelete(
+ Object entity,
+ Object id,
+ Object[] state,
+ String[] propertyNames,
+ Type[] types) {
+ if (entity instanceof SoftDeletable soft) {
+ // Chặn DELETE -> UPDATE
+ soft.markDeleted(SecurityContextHolder
+ .getContext()
+ .getAuthentication()
+ .getName());
+
+ // Đẩy giá trị vào state[] để Hibernate cập nhật DB
+ for (int i = 0; i < propertyNames.length; i++) {
+ switch (propertyNames[i]) {
+ case "deletedBy" -> state[i] = soft.getDeletedBy();
+ case "deletedAt" -> state[i] = Instant.now();
+ }
+ }
+ } else {
+ super.onDelete(entity, id, state, propertyNames, types);
+ }
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java b/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java
new file mode 100644
index 00000000..b50fa057
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/constant/PostAccessEnum.java
@@ -0,0 +1,6 @@
+package com.codecampus.post.constant;
+
+public enum PostAccessEnum {
+ PUBLIC,
+ PRIVATE,
+}
diff --git a/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java b/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java
new file mode 100644
index 00000000..3a6bbfc6
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/constant/config/SecurityConfigConstant.java
@@ -0,0 +1,35 @@
+package com.codecampus.post.constant.config;
+
+import lombok.NoArgsConstructor;
+
+@NoArgsConstructor
+public class SecurityConfigConstant {
+ // HEADER REQUEST
+ public static final String AUTHORIZATION_HEADER = "Authorization";
+ public static final String CONTENT_TYPE_HEADER = "Content-Type";
+ public static final String ACCEPT_HEADER = "Accept";
+
+
+ // PATTERN REQUEST
+ public static final String URL_PATTERN_ALL = "/**";
+
+ // PUBLIC ENDPOINT
+ public static final String[] PUBLIC_ENDPOINTS = {
+ "/hello",
+ "/health",
+ "/actuator/**",
+ };
+
+// // FRONTEND ENDPOINT
+// public static final String FRONTEND_ENDPOINT = "http://192.168.1.30:4200";
+// public static final String FRONTEND_ENDPOINT2 = "http://localhost:4200";
+// public static final String FRONTEND_ENDPOINT3 = "http://127.0.0.1:5500";
+
+ // METHOD ALLOWED
+ public static String GET_METHOD = "GET";
+ public static String POST_METHOD = "POST";
+ public static String DELETE_METHOD = "DELETE";
+ public static String PUT_METHOD = "PUT";
+ public static String PATCH_METHOD = "PATCH";
+ public static String OPTIONS_METHOD = "OPTIONS";
+}
diff --git a/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java b/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java
new file mode 100644
index 00000000..86a814e6
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/constant/exception/ErrorCodeConstant.java
@@ -0,0 +1,20 @@
+package com.codecampus.post.constant.exception;
+
+import lombok.NoArgsConstructor;
+
+import static com.sun.source.doctree.DocTree.Kind.AUTHOR;
+import static io.grpc.Status.NOT_FOUND;
+import static org.springframework.http.HttpStatus.*;
+
+@NoArgsConstructor
+public class ErrorCodeConstant {
+ public static final String INTERNAL_SERVER_STATUS =
+ INTERNAL_SERVER_ERROR.toString();
+ public static final String BAD_REQUEST_STATUS = BAD_REQUEST.toString();
+ public static final String UNAUTHORIZED_STATUS =
+ org.springframework.http.HttpStatus.UNAUTHORIZED.toString();
+ public static final String FORBIDDEN_STATUS = FORBIDDEN.toString();
+ public static final String NOT_FOUND_STATUS = NOT_FOUND.toString();
+ public static final String CONFLICT_STATUS = CONFLICT.toString();
+ public static final String AUTHOR_STATUS = AUTHOR.toString();
+}
diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java b/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java
new file mode 100644
index 00000000..97e9ad17
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/controller/PostAccessController.java
@@ -0,0 +1,44 @@
+package com.codecampus.post.controller;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.codecampus.post.dto.request.PostAccessRequestDto;
+import com.codecampus.post.entity.PostAccess;
+import com.codecampus.post.service.PostAccessService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/postAccess")
+@RequiredArgsConstructor
+public class PostAccessController {
+
+ private final PostAccessService postAccessService;
+
+ @PostMapping("/addAccess")
+ public ResponseEntity> addOrUpdateAccess(@RequestBody PostAccessRequestDto dto) {
+ postAccessService.saveOrUpdateAccess(dto);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Access list updated successfully")
+ .build());
+ }
+
+ @DeleteMapping("/deleteAccess")
+ public ResponseEntity> deleteAccess(
+ @RequestBody PostAccessRequestDto userIds) {
+ postAccessService.deleteAccess( userIds);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Access list deleted successfully")
+ .build());
+ }
+
+ @GetMapping("/getPostAccess/{postId}")
+ public ResponseEntity> getAccessList(@PathVariable String postId) {
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Access list retrieved successfully")
+ .result(postAccessService.getAccessByPostId(postId))
+ .build());
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java b/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java
new file mode 100644
index 00000000..7ed27667
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/controller/PostCommentController.java
@@ -0,0 +1,80 @@
+package com.codecampus.post.controller;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.codecampus.post.dto.request.CommentRequestDto;
+import com.codecampus.post.dto.request.UpdateCommentDto;
+import com.codecampus.post.dto.response.CommentResponseDto;
+import com.codecampus.post.entity.PostComment;
+import com.codecampus.post.service.PostCommentService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/comments")
+@RequiredArgsConstructor
+public class PostCommentController {
+
+ private final PostCommentService postCommentService;
+
+ @PostMapping("/addComment")
+ public ResponseEntity> addComment(
+ @RequestBody CommentRequestDto dto,
+ HttpServletRequest request
+ ) {
+
+ PostComment saved = postCommentService.addComment(dto, request);
+ return ResponseEntity.ok(
+ ApiResponse.builder()
+ .message("Thêm bình luận thành công")
+ .build()
+ );
+ }
+
+ @GetMapping("/getCmtByPostId/{postId}")
+ public ResponseEntity> getComments(@PathVariable String postId) {
+ List comments = postCommentService.getCommentsByPost(postId);
+ return ResponseEntity.ok(
+ ApiResponse.builder()
+ .message("Lấy bình luận thành công")
+ .result(comments)
+ .build()
+ );
+ }
+
+ @PutMapping("/updateComment")
+ public ResponseEntity> updateComment(
+ @RequestBody UpdateCommentDto requestDto,
+ HttpServletRequest request
+ ) {
+ postCommentService.updateComment(requestDto, request);
+ return ResponseEntity.ok(
+ ApiResponse.builder()
+ .message("Bình luận chỉnh sửa thành công")
+ .build()
+ );
+ }
+
+ @DeleteMapping("/deleteComment/{commentId}")
+ public ResponseEntity> deleteComment(
+ @PathVariable String commentId,
+ HttpServletRequest request
+ ) {
+ postCommentService.deleteComment(commentId, request);
+ return ResponseEntity.ok(
+ ApiResponse.builder()
+ .message("Xoá bình luận thành công")
+ .build()
+ );
+ }
+
+ private String extractUserIdFromToken(HttpServletRequest request) {
+ String token = request.getHeader("Authorization").substring(7);
+ // giả sử bạn có customJwtDecoder như ở PostService
+ return "decodedUserId"; // thay bằng code decode thực tế
+ }
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostController.java b/post-service/src/main/java/com/codecampus/post/controller/PostController.java
new file mode 100644
index 00000000..145be9b1
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/controller/PostController.java
@@ -0,0 +1,73 @@
+package com.codecampus.post.controller;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.codecampus.post.dto.common.PageRequestDto;
+import com.codecampus.post.dto.request.AddFileDocumentDto;
+import com.codecampus.post.dto.request.PostRequestDto;
+import com.codecampus.post.service.PostService;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+@RestController()
+@RequestMapping("/posts")
+public class PostController {
+
+ @Autowired
+ private PostService postService;
+
+ @GetMapping("/getAllAccessiblePosts")
+ public ResponseEntity> getAllAccessiblePosts(HttpServletRequest request, @RequestBody PageRequestDto pageRequestDto) {
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Success")
+ .result(postService.getAllAccessiblePosts(request, pageRequestDto))
+ .build());
+ }
+
+ @GetMapping("/seachPosts/{searchText}")
+ public ResponseEntity> SeachPosts(@PathVariable("searchText") String searchText, HttpServletRequest request, @RequestBody PageRequestDto pageRequestDto) {
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Success")
+ .result(postService.SeachPosts(searchText, request, pageRequestDto))
+ .build());
+ }
+
+// @GetMapping("/getPostByIdIfAccessible/{postId}")
+// public ResponseEntity> getPostByIdIfAccessible(@PathVariable("postId") String postId, HttpServletRequest request) {
+// return ResponseEntity.ok(ApiResponse.builder()
+// .message("Success")
+// .result(postService.getPostByIdIfAccessible(postId, request))
+// .build());
+// }
+
+ @PostMapping("/createPost")
+ public ResponseEntity> createPost(
+ @ModelAttribute PostRequestDto postRequestDto,
+ HttpServletRequest request
+ ) {
+ postService.createPost(postRequestDto, request);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Post created successfully")
+ .build());
+ }
+
+ @PutMapping("/updatePost")
+ public ResponseEntity> updatePost(
+ @ModelAttribute PostRequestDto postRequestDto,
+ HttpServletRequest request
+ ) {
+ postService.updatePost(postRequestDto, request);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Post updated successfully")
+ .build());
+ }
+
+ @PutMapping("/deletePost/{postId}")
+ public ResponseEntity> deletePost(@PathVariable("postId") String postId, HttpServletRequest request) {
+ postService.deletePost(postId, request);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Post deleted successfully")
+ .build());
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java b/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java
new file mode 100644
index 00000000..4062bf56
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/controller/PostReactionController.java
@@ -0,0 +1,50 @@
+package com.codecampus.post.controller;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.codecampus.post.dto.request.PostReactionRequestDto;
+import com.codecampus.post.service.PostReactionService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/postReaction")
+@RequiredArgsConstructor
+public class PostReactionController {
+
+ private final PostReactionService postReactionService;
+
+ // Toggle reaction (upvote / downvote)
+ @PostMapping("/toggle")
+ public ResponseEntity> toggleReaction(@RequestBody PostReactionRequestDto requestDto,
+ HttpServletRequest request) {
+ postReactionService.toggleReaction(requestDto, request);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Reaction toggled successfully")
+ .build());
+ }
+
+ // Get reaction count cho post
+ @GetMapping("/post/{postId}")
+ public ResponseEntity> getPostReactions(@PathVariable String postId) {
+ Map counts = postReactionService.getReactionCount(postId, null);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Reaction counts retrieved successfully")
+ .result(counts)
+ .build());
+ }
+
+ // Get reaction count cho comment trong post
+ @GetMapping("/post/{postId}/comment/{commentId}")
+ public ResponseEntity> getCommentReactions(@PathVariable String postId,
+ @PathVariable String commentId) {
+ Map counts = postReactionService.getReactionCount(postId, commentId);
+ return ResponseEntity.ok(ApiResponse.builder()
+ .message("Reaction counts retrieved successfully")
+ .result(counts)
+ .build());
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java b/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java
new file mode 100644
index 00000000..e108de9e
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/common/ApiResponse.java
@@ -0,0 +1,25 @@
+package com.codecampus.post.dto.common;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.*;
+import lombok.experimental.FieldDefaults;
+
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@FieldDefaults(level = AccessLevel.PRIVATE)
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ApiResponse {
+ // Mã phản hồi mặc định (20000) cho các phản hồi thành công
+ @Builder.Default
+ int code = 20000;
+
+ String message;
+
+ // Trạn thái phản hồi mặc định (mã 20000) cho các phản hồi thành công
+ @Builder.Default
+ String status = "Thành công!";
+
+ private T result;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java
new file mode 100644
index 00000000..e11d3ee4
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/common/PageRequestDto.java
@@ -0,0 +1,11 @@
+package com.codecampus.post.dto.common;
+
+import lombok.Data;
+
+@Data
+public class PageRequestDto {
+ private int page;
+ private int size;
+ private String sortBy;
+ private String sortDirection;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java b/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java
new file mode 100644
index 00000000..3d0d191e
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/common/PageResponse.java
@@ -0,0 +1,25 @@
+package com.codecampus.post.dto.common;
+
+import lombok.AccessLevel;
+import lombok.Builder;
+import lombok.Data;
+import lombok.experimental.FieldDefaults;
+
+import java.util.Collections;
+import java.util.List;
+
+@Data
+@Builder
+@FieldDefaults(level = AccessLevel.PRIVATE)
+// Mặc định cho phép response cả null khi Dev
+// Khi build thì KHÔNG response null
+//@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PageResponse {
+ int currentPage;
+ int totalPages;
+ int pageSize;
+ long totalElements;
+
+ @Builder.Default
+ private List data = Collections.emptyList();
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java
new file mode 100644
index 00000000..2afac42d
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/AddFileDocumentDto.java
@@ -0,0 +1,21 @@
+package com.codecampus.post.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.UUID;
+
+@Data
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class AddFileDocumentDto {
+ private MultipartFile file;
+ private String category; // enum truyền dạng string
+ private String description;
+ private List tags;
+ private boolean isLectureVideo = false;
+ private boolean isTextbook = false;
+ private UUID orgId;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java
new file mode 100644
index 00000000..e2449c00
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/CommentRequestDto.java
@@ -0,0 +1,16 @@
+package com.codecampus.post.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class CommentRequestDto {
+ private String postId;
+ private String parentCommentId; // null nếu là comment gốc
+ private String content;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java
new file mode 100644
index 00000000..b8fa8531
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostAccessRequestDto.java
@@ -0,0 +1,16 @@
+package com.codecampus.post.dto.request;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class PostAccessRequestDto {
+ private String postId;
+ private List userIds;
+ private Boolean isExcluded;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java
new file mode 100644
index 00000000..4d614630
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostReactionRequestDto.java
@@ -0,0 +1,18 @@
+package com.codecampus.post.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@Builder
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PostReactionRequestDto {
+ private String postId;
+ private String userId;
+ private String commentId; // for comment reactions, can be null if reacting to the post itself
+ private String reactionType; // upvote || downvote
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java
new file mode 100644
index 00000000..aa884929
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/PostRequestDto.java
@@ -0,0 +1,28 @@
+package com.codecampus.post.dto.request;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Getter;
+import lombok.Setter;
+
+
+@Getter
+@Setter
+@Builder
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PostRequestDto {
+ private String postId; // for update post
+ private String title;
+ private String orgId;
+ private String content;
+ private String oldImgesUrls; // for update post img
+ private boolean isPublic;
+ private boolean allowComment;
+ private String postType;
+ private String hashtag;
+ private String status;
+ private AddFileDocumentDto fileDocument; // for add file document
+// private PostAccess postAccess;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java b/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java
new file mode 100644
index 00000000..9a03c5d6
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/request/UpdateCommentDto.java
@@ -0,0 +1,13 @@
+package com.codecampus.post.dto.request;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+@AllArgsConstructor
+public class UpdateCommentDto {
+ private String commentId;
+ private String content;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java
new file mode 100644
index 00000000..16ff566d
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/response/AddFileResponseDto.java
@@ -0,0 +1,13 @@
+package com.codecampus.post.dto.response;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class AddFileResponseDto {
+ private int code;
+ private String message;
+ private String status;
+ private String result;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java
new file mode 100644
index 00000000..f40abf28
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/response/CommentResponseDto.java
@@ -0,0 +1,18 @@
+package com.codecampus.post.dto.response;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+@Data
+@AllArgsConstructor
+@NoArgsConstructor
+public class CommentResponseDto {
+ private String commentId;
+ private String userId;
+ private String content;
+ private List replies;
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java b/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java
new file mode 100644
index 00000000..eb057b5b
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/dto/response/PostAccessResponseDto.java
@@ -0,0 +1,12 @@
+package com.codecampus.post.dto.response;
+
+import lombok.Builder;
+import lombok.Data;
+
+@Data
+@Builder
+public class PostAccessResponseDto {
+ private String postId;
+ private String userId;
+ private boolean isExcluded;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/entity/Post.java b/post-service/src/main/java/com/codecampus/post/entity/Post.java
new file mode 100644
index 00000000..2e87851b
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/Post.java
@@ -0,0 +1,41 @@
+package com.codecampus.post.entity;
+
+import com.codecampus.post.entity.audit.AuditMetadata;
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.util.List;
+
+@Entity
+@Getter
+@Setter
+@Table(name = "post")
+public class Post extends AuditMetadata {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String postId;
+
+ private String userId;
+ private String orgId;
+ private String postType; //global, organization, group, etc.
+ private String title;
+ private String content;
+ private Boolean isPublic;
+ private Boolean allowComment;
+ private String hashtag;
+ private String status;
+ private List imagesUrls;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
+ @JsonIgnore
+ private List comments;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
+ private List reactions;
+
+ @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
+ private List accesses;
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java b/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java
new file mode 100644
index 00000000..487d4d5e
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/PostAccess.java
@@ -0,0 +1,24 @@
+package com.codecampus.post.entity;
+
+import com.codecampus.post.entity.audit.AuditMetadata;
+import jakarta.persistence.*;
+import lombok.Data;
+import org.hibernate.annotations.Fetch;
+import org.springframework.context.annotation.Lazy;
+
+@Entity
+@Data
+@Table(name = "post_access")
+public class PostAccess extends AuditMetadata {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String postAccessId;
+
+ @ManyToOne(fetch = FetchType.LAZY)
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ private String userId;
+ private Boolean isExcluded;
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostComment.java b/post-service/src/main/java/com/codecampus/post/entity/PostComment.java
new file mode 100644
index 00000000..2d4f12e6
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/PostComment.java
@@ -0,0 +1,32 @@
+package com.codecampus.post.entity;
+
+import com.codecampus.post.entity.audit.AuditMetadata;
+import jakarta.persistence.*;
+import lombok.Data;
+
+import java.util.List;
+
+@Entity
+@Data
+@Table(name = "post_comment")
+public class PostComment extends AuditMetadata {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String commentId;
+
+ private String userId;
+ private String content;
+
+
+ @ManyToOne
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+
+ @ManyToOne
+ @JoinColumn(name = "parent_comment_id")
+ private PostComment parentComment; // comment reply
+
+ @OneToMany(mappedBy = "parentComment", cascade = CascadeType.ALL)
+ private List replies;
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostImage.java b/post-service/src/main/java/com/codecampus/post/entity/PostImage.java
new file mode 100644
index 00000000..087c81bd
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/PostImage.java
@@ -0,0 +1,23 @@
+//package com.codecampus.post.entity;
+//
+//import com.codecampus.post.entity.audit.AuditMetadata;
+//import jakarta.persistence.*;
+//
+//import java.time.LocalDateTime;
+//import java.util.UUID;
+//
+//@Entity
+//@Table(name = "post_image")
+//public class PostImage extends AuditMetadata {
+// @Id
+// @GeneratedValue(strategy = GenerationType.UUID)
+// private String postImageId;
+//
+// private String imageUrl;
+// private String altText;
+//
+// @ManyToOne
+// @JoinColumn(name = "post_id", nullable = false)
+// private Post post;
+//}
+//
diff --git a/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java b/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java
new file mode 100644
index 00000000..30b583e4
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/PostReaction.java
@@ -0,0 +1,25 @@
+package com.codecampus.post.entity;
+
+import com.codecampus.post.entity.audit.AuditMetadata;
+import jakarta.persistence.*;
+import lombok.Getter;
+import lombok.Setter;
+
+@Entity
+@Getter
+@Setter
+@Table(name = "post_reaction")
+public class PostReaction extends AuditMetadata {
+ @Id
+ @GeneratedValue(strategy = GenerationType.UUID)
+ private String reactionId;
+
+ private String userId;
+ private String commentId; // for comment reactions
+ private String emojiType;
+
+
+ @ManyToOne
+ @JoinColumn(name = "post_id", nullable = false)
+ private Post post;
+}
diff --git a/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java b/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java
new file mode 100644
index 00000000..222709a1
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/audit/AuditMetadata.java
@@ -0,0 +1,57 @@
+package com.codecampus.post.entity.audit;
+
+import jakarta.persistence.Column;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.MappedSuperclass;
+import lombok.AccessLevel;
+import lombok.Data;
+import lombok.experimental.FieldDefaults;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.annotation.LastModifiedBy;
+import org.springframework.data.annotation.LastModifiedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.Instant;
+
+@Data
+@MappedSuperclass
+@EntityListeners(AuditingEntityListener.class)
+@FieldDefaults(level = AccessLevel.PROTECTED)
+public class AuditMetadata implements SoftDeletable {
+ /* ---- create ---- */
+ @CreatedBy
+ @Column(name = "created_by")
+ String createdBy;
+
+ @CreatedDate
+ @Column(name = "created_at")
+ Instant createdAt;
+
+ /* ---- update ---- */
+ @LastModifiedBy
+ @Column(name = "updated_by")
+ String updatedBy;
+
+ @LastModifiedDate
+ @Column(name = "updated_at")
+ Instant updatedAt;
+
+ /* ---- soft-delete ---- */
+ @Column(name = "deleted_by")
+ String deletedBy;
+
+ @Column(name = "deleted_at")
+ Instant deletedAt;
+
+ @Override
+ public void markDeleted(String by) {
+ this.deletedBy = by;
+ this.deletedAt = Instant.now();
+ }
+
+ @Override
+ public boolean isDeleted() {
+ return deletedAt != null;
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java b/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java
new file mode 100644
index 00000000..991bc432
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/entity/audit/SoftDeletable.java
@@ -0,0 +1,9 @@
+package com.codecampus.post.entity.audit;
+
+public interface SoftDeletable {
+ void markDeleted(String by);
+
+ boolean isDeleted();
+
+ String getDeletedBy();
+}
diff --git a/post-service/src/main/java/com/codecampus/post/exception/AppException.java b/post-service/src/main/java/com/codecampus/post/exception/AppException.java
new file mode 100644
index 00000000..434d3af2
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/exception/AppException.java
@@ -0,0 +1,15 @@
+package com.codecampus.post.exception;
+
+import lombok.Getter;
+import lombok.Setter;
+
+@Getter
+@Setter
+public class AppException extends RuntimeException {
+ private ErrorCode errorCode;
+
+ public AppException(ErrorCode errorCode) {
+ super(errorCode.getMessage());
+ this.errorCode = errorCode;
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java b/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java
new file mode 100644
index 00000000..08776a21
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/exception/ErrorCode.java
@@ -0,0 +1,65 @@
+package com.codecampus.post.exception;
+
+import lombok.Getter;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.HttpStatusCode;
+
+import static com.codecampus.post.constant.exception.ErrorCodeConstant.*;
+import static org.springframework.http.HttpStatus.*;
+
+@Getter
+public enum ErrorCode {
+ // Các lỗi không phân loại và lỗi chung
+ UNCATEGORIZED_EXCEPTION(99999, INTERNAL_SERVER_STATUS,
+ "Lỗi chưa phân loại", INTERNAL_SERVER_ERROR),
+
+ // 400 - Bad Request
+
+
+ // 401 - Unauthorized
+ UNAUTHENTICATED(4019001, UNAUTHORIZED_STATUS, "Chưa xác thực!",
+ HttpStatus.UNAUTHORIZED),
+ AUTHOR_UNAUTHORIZED(4019002,AUTHOR_STATUS, "Không phải người đăng bài",
+ HttpStatus.UNAUTHORIZED),
+
+ // 403 - Forbidden
+ UNAUTHORIZED(4039001, FORBIDDEN_STATUS, "Bạn không có quyền truy cập!",
+ FORBIDDEN),
+
+ // 404 - Not Found
+ USER_NOT_FOUND(4049001, NOT_FOUND_STATUS, "Không tìm thấy người dùng!",
+ NOT_FOUND),
+ TARGET_USER_NOT_FOUND(4049003, NOT_FOUND_STATUS,
+ "Không tìm thấy người dùng mục tiêu!", NOT_FOUND),
+ POST_NOT_FOUND(4049004, NOT_FOUND_STATUS, "Không tìm thấy bài đăng!",
+ NOT_FOUND),
+ ORG_NOT_FOUND(4049004, NOT_FOUND_STATUS, "Không tìm thấy tổ chức!",
+ NOT_FOUND),
+
+
+ // 409 - Conflict
+ USER_ALREADY_EXISTS(4099001, CONFLICT_STATUS, "Người dùng đã tồn tại!",
+ CONFLICT),
+
+ //410 - invalid
+ INVALID_FILE_TYPE(4109001, BAD_REQUEST_STATUS, "Định dạng tệp không hợp lệ",
+ HttpStatus.BAD_REQUEST)
+
+ ;
+
+ private final int code;
+ private final String status;
+ private final String message;
+ private final HttpStatusCode statusCode;
+
+ ErrorCode(
+ int code,
+ String status,
+ String message,
+ HttpStatusCode statusCode) {
+ this.code = code;
+ this.status = status;
+ this.message = message;
+ this.statusCode = statusCode;
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java b/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java
new file mode 100644
index 00000000..349345cc
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/exception/GlobalExceptionHandler.java
@@ -0,0 +1,102 @@
+package com.codecampus.post.exception;
+
+import com.codecampus.post.dto.common.ApiResponse;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import feign.FeignException;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.AccessDeniedException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+
+@ControllerAdvice
+@Slf4j
+@RequiredArgsConstructor
+public class GlobalExceptionHandler {
+
+ private final ObjectMapper objectMapper;
+
+ /**
+ * Xử lý các ngoại lệ RuntimeException không được bắt riêng.
+ *
+ * @param exception ngoại lệ RuntimeException được ném ra
+ * @return ResponseEntity chứa ApiResponse với mã lỗi, thông điệp và trạng thái lỗi tương ứng
+ */
+ @ExceptionHandler(value = Exception.class)
+ ResponseEntity> handlingRuntimeException(
+ RuntimeException exception) {
+ log.error("Exception: " + exception.getMessage(), exception);
+
+ ApiResponse> apiResponse = new ApiResponse<>();
+ apiResponse.setCode(ErrorCode.UNCATEGORIZED_EXCEPTION.getCode());
+ apiResponse.setMessage(ErrorCode.UNCATEGORIZED_EXCEPTION.getMessage());
+ apiResponse.setStatus(ErrorCode.UNCATEGORIZED_EXCEPTION.getStatus());
+
+ return ResponseEntity.badRequest().body(apiResponse);
+ }
+
+ /**
+ * Xử lý ngoại lệ ứng dụng (AppException) và chuyển thành ApiResponse.
+ *
+ * @param exception ngoại lệ AppException được ném ra
+ * @return ResponseEntity chứa ApiResponse với thông tin lỗi tương ứng
+ */
+ @ExceptionHandler(value = AppException.class)
+ ResponseEntity> handlingAppException(
+ AppException exception) {
+ log.error("Exception: " + exception.getMessage(), exception);
+
+ ErrorCode errorCode = exception.getErrorCode();
+ ApiResponse> apiResponse = new ApiResponse<>();
+ apiResponse.setCode(errorCode.getCode());
+ apiResponse.setMessage(errorCode.getMessage());
+ apiResponse.setStatus(errorCode.getStatus());
+
+ return ResponseEntity.status(errorCode.getStatusCode())
+ .body(apiResponse);
+ }
+
+ /**
+ * Xử lý ngoại lệ AccessDeniedException khi người dùng không có quyền truy cập.
+ *
+ * @param exception ngoại lệ AccessDeniedException
+ * @return ResponseEntity chứa ApiResponse với thông tin lỗi truy cập
+ */
+ @ExceptionHandler(value = AccessDeniedException.class)
+ ResponseEntity> handlingAccessDeniedException(
+ AccessDeniedException exception) {
+ ErrorCode errorCode = ErrorCode.UNAUTHORIZED;
+
+ ApiResponse> apiResponse = ApiResponse.builder()
+ .code(errorCode.getCode())
+ .message(errorCode.getMessage())
+ .status(errorCode.getStatus())
+ .build();
+
+ return ResponseEntity.status(errorCode.getStatusCode())
+ .body(apiResponse);
+ }
+
+ @ExceptionHandler(value = FeignException.class)
+ ResponseEntity> handlingFeignException(FeignException exception) {
+ log.error("FeignException: " + exception.getMessage(), exception);
+
+ try {
+ // Lấy body lỗi từ service kia (UTF-8)
+ String body = exception.contentUTF8();
+
+ // Parse về ApiResponse nếu format giống của mình
+ ApiResponse> apiResponse = objectMapper.readValue(body, ApiResponse.class);
+
+ return ResponseEntity.status(exception.status())
+ .body(apiResponse);
+
+ } catch (Exception e) {
+ // Nếu parse thất bại thì trả body thô
+ return ResponseEntity.status(exception.status())
+ .body(exception.contentUTF8());
+ }
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java
new file mode 100644
index 00000000..35051b8e
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/repository/PostAccessRepository.java
@@ -0,0 +1,16 @@
+package com.codecampus.post.repository;
+
+import com.codecampus.post.entity.PostAccess;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface PostAccessRepository extends JpaRepository {
+
+ List findByPost_PostId(String postId);
+
+ void deleteByPost_PostIdAndUserIdIn(String postId, List userIds);
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java
new file mode 100644
index 00000000..338d3508
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/repository/PostCommentRepository.java
@@ -0,0 +1,16 @@
+package com.codecampus.post.repository;
+
+import com.codecampus.post.entity.PostComment;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+
+@Repository
+public interface PostCommentRepository extends JpaRepository {
+
+ List findByPost_PostIdAndParentCommentIsNullOrderByCommentIdDesc(String postId);
+
+ List findByParentComment_CommentIdOrderByCommentIdAsc(String parentCommentId);
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java
new file mode 100644
index 00000000..69725e22
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/repository/PostReactionRepository.java
@@ -0,0 +1,16 @@
+package com.codecampus.post.repository;
+
+import com.codecampus.post.entity.PostAccess;
+import com.codecampus.post.entity.PostReaction;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+import java.util.Optional;
+
+@Repository
+public interface PostReactionRepository extends JpaRepository {
+ Optional findByPost_PostIdAndUserId(String postId, String userId);
+ Optional findByPost_PostIdAndUserIdAndCommentId(String postId, String userId, String commentId);
+ long countByPost_PostIdAndEmojiType(String postId, String emojiType);
+ long countByPost_PostIdAndCommentIdAndEmojiType(String postId, String commentId, String emojiType);
+}
diff --git a/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java b/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java
new file mode 100644
index 00000000..f421350b
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/repository/PostRepository.java
@@ -0,0 +1,62 @@
+package com.codecampus.post.repository;
+
+import com.codecampus.post.entity.Post;
+import feign.Param;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.stereotype.Repository;
+
+import java.util.List;
+import java.util.Optional;
+
+@Repository
+public interface PostRepository extends JpaRepository {
+ @Query("""
+ SELECT DISTINCT p FROM Post p
+ LEFT JOIN p.accesses a
+ WHERE p.isPublic = true OR a.userId = :userId
+""")
+ List findAccessiblePosts(String userId);
+
+ @Query("""
+ SELECT p FROM Post p
+ LEFT JOIN p.accesses a
+ WHERE p.postId = :postId AND (p.isPublic = true OR a.userId = :userId)
+""")
+ Optional findAccessiblePostById(String postId, String userId);
+
+ @Query("""
+ SELECT DISTINCT p FROM Post p
+ LEFT JOIN p.accesses pa
+ WHERE p.deletedAt IS NULL
+ AND (
+ p.postType = 'Global'
+ OR p.isPublic = true
+ OR (pa.userId = :userId AND (pa.isExcluded IS NULL OR pa.isExcluded = false))
+ )
+""")
+ Page findAllVisiblePosts(String userId, Pageable pageable);
+
+ @Query(value = """
+ SELECT DISTINCT p.*
+ FROM post p
+ LEFT JOIN post_access pa ON p.post_id = pa.post_id
+ WHERE p.deleted_at IS NULL
+ AND p.search_vector @@ plainto_tsquery('simple', :searchText)
+ AND (
+ p.post_type = 'Global'
+ OR p.is_public = true
+ OR (pa.user_id = :userId AND (pa.is_excluded IS NULL OR pa.is_excluded = false))
+ )
+ """,
+ nativeQuery = true)
+ Page searchVisiblePosts(
+ @Param("searchText") String searchText,
+ @Param("userId") String userId,
+ Pageable pageable
+ );
+
+
+}
diff --git a/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java
new file mode 100644
index 00000000..934b5d79
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FeignMultipartSupportConfig.java
@@ -0,0 +1,24 @@
+package com.codecampus.post.service.FeignConfig;
+
+import feign.codec.Encoder;
+import feign.form.spring.SpringFormEncoder;
+import org.springframework.cloud.openfeign.support.SpringEncoder;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.beans.factory.ObjectFactory;
+import org.springframework.boot.autoconfigure.http.HttpMessageConverters;
+
+@Configuration
+public class FeignMultipartSupportConfig {
+
+ private final ObjectFactory messageConverters;
+
+ public FeignMultipartSupportConfig(ObjectFactory messageConverters) {
+ this.messageConverters = messageConverters;
+ }
+
+ @Bean
+ public Encoder feignFormEncoder() {
+ return new SpringFormEncoder(new SpringEncoder(messageConverters));
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java
new file mode 100644
index 00000000..5ceab4cf
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/FeignConfig/FileServiceClient.java
@@ -0,0 +1,20 @@
+package com.codecampus.post.service.FeignConfig;
+
+import com.codecampus.post.dto.request.AddFileDocumentDto;
+import com.codecampus.post.dto.response.AddFileResponseDto;
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.http.MediaType;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PostMapping;
+
+@FeignClient(name = "file-service",
+ url = "http://localhost:8082",
+ configuration = FeignMultipartSupportConfig.class)
+public interface FileServiceClient {
+
+ @PostMapping(
+ value = "/file/api/FileDocument/add",
+ consumes = MediaType.MULTIPART_FORM_DATA_VALUE
+ )
+ AddFileResponseDto uploadFile(@ModelAttribute AddFileDocumentDto dto);
+}
diff --git a/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java b/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java
new file mode 100644
index 00000000..e41aaad3
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/PostAccessService.java
@@ -0,0 +1,74 @@
+package com.codecampus.post.service;
+
+import com.codecampus.post.dto.response.PostAccessResponseDto;
+import com.codecampus.post.entity.PostAccess;
+import com.codecampus.post.dto.request.PostAccessRequestDto;
+import com.codecampus.post.entity.Post;
+import com.codecampus.post.repository.PostAccessRepository;
+import com.codecampus.post.repository.PostRepository;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class PostAccessService {
+
+ private final PostAccessRepository postAccessRepository;
+ private final PostRepository postRepository;
+
+ // Thêm hoặc cập nhật quyền truy cập cho nhiều user
+ @Transactional
+ public void saveOrUpdateAccess(PostAccessRequestDto dto) {
+ Post post = postRepository.findById(dto.getPostId())
+ .orElseThrow(() -> new RuntimeException("Post not found"));
+
+ // lấy danh sách userId đã có
+ Set existingUserIds = postAccessRepository.findByPost_PostId(dto.getPostId())
+ .stream()
+ .map(PostAccess::getUserId)
+ .collect(Collectors.toSet());
+
+ // chỉ giữ lại userId chưa có
+ List newAccessList = dto.getUserIds().stream()
+ .filter(userId -> !existingUserIds.contains(userId))
+ .map(userId -> {
+ PostAccess pa = new PostAccess();
+ pa.setPost(post);
+ pa.setUserId(userId);
+ pa.setIsExcluded(dto.getIsExcluded());
+ return pa;
+ })
+ .toList();
+
+ if (!newAccessList.isEmpty()) {
+ postAccessRepository.saveAll(newAccessList);
+ }
+ }
+
+
+ // Xoá quyền của nhiều user
+ @Transactional
+ public void deleteAccess(PostAccessRequestDto dto) {
+ postAccessRepository.deleteByPost_PostIdAndUserIdIn(dto.getPostId(), dto.getUserIds());
+ }
+
+ // Lấy danh sách quyền của 1 bài post
+ @Transactional
+ public List getAccessByPostId(String postId) {
+ return postAccessRepository.findByPost_PostId(postId).stream()
+ .map(pa -> PostAccessResponseDto.builder()
+ .postId(pa.getPost().getPostId())
+ .userId(pa.getUserId())
+ .isExcluded(Boolean.TRUE.equals(pa.getIsExcluded()))
+ .build()
+ )
+ .toList();
+ }
+
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java b/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java
new file mode 100644
index 00000000..48da42f1
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/PostCommentService.java
@@ -0,0 +1,166 @@
+package com.codecampus.post.service;
+
+import com.codecampus.post.config.CustomJwtDecoder;
+import com.codecampus.post.dto.request.CommentRequestDto;
+import com.codecampus.post.dto.request.UpdateCommentDto;
+import com.codecampus.post.dto.response.CommentResponseDto;
+import com.codecampus.post.entity.Post;
+import com.codecampus.post.entity.PostComment;
+import com.codecampus.post.repository.PostCommentRepository;
+import com.codecampus.post.repository.PostRepository;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+
+@Service
+@RequiredArgsConstructor
+public class PostCommentService {
+
+ private final PostCommentRepository postCommentRepository;
+ private final PostRepository postRepository;
+ private final CustomJwtDecoder customJwtDecoder;
+
+ @Transactional
+ public PostComment addComment(CommentRequestDto dto, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ if(token == null || !token.startsWith("Bearer ")) {
+ throw new IllegalArgumentException("Authorization token is missing or invalid");
+ }
+ String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); // Assuming user ID is stored in the principal
+
+ Post post = postRepository.findById(dto.getPostId())
+ .orElseThrow(() -> new RuntimeException("Post not found"));
+
+ PostComment comment = new PostComment();
+ comment.setPost(post);
+ comment.setUserId(userId);
+ comment.setContent(dto.getContent());
+
+ if (dto.getParentCommentId() != null) {
+ PostComment parent = postCommentRepository.findById(dto.getParentCommentId())
+ .orElseThrow(() -> new RuntimeException("Parent comment not found"));
+ comment.setParentComment(parent);
+ }
+
+ return postCommentRepository.save(comment);
+ }
+
+ public List getCommentsByPost(String postId) {
+ List parents = postCommentRepository
+ .findByPost_PostIdAndParentCommentIsNullOrderByCommentIdDesc(postId);
+
+ return parents.stream()
+ .filter(c -> !c.isDeleted()) // chỉ lấy comment chưa xóa
+ .map(c -> mapWithLimitDepth(c, 1)) // bắt đầu từ depth = 1
+ .toList();
+ }
+
+ private CommentResponseDto mapWithLimitDepth(PostComment comment, int depth) {
+ List replyDtos;
+
+ if (depth < 2) {
+ // cấp 1 -> cho phép lấy reply
+ replyDtos = comment.getReplies() != null
+ ? comment.getReplies().stream()
+ .filter(r -> !r.isDeleted())
+ .flatMap(r -> {
+ if (depth + 1 < 2) {
+ // vẫn còn trong giới hạn, tiếp tục đệ quy
+ return Stream.of(mapWithLimitDepth(r, depth + 1));
+ } else {
+ // cấp 2 rồi, gom hết con/cháu thành cấp 2 luôn
+ return flattenReplies(r).stream();
+ }
+ })
+ .toList()
+ : List.of();
+ } else {
+ // depth >= 2 thì không bao giờ xảy ra vì ta dừng ở depth=1 rồi flatten
+ replyDtos = List.of();
+ }
+
+ return new CommentResponseDto(
+ comment.getCommentId(),
+ comment.getUserId(),
+ comment.getContent(),
+ replyDtos
+ );
+ }
+
+ /**
+ * Flatten toàn bộ reply từ comment con (cấp >= 2) thành danh sách cấp 2
+ */
+ private List flattenReplies(PostComment comment) {
+ List flatList = new ArrayList<>();
+
+ if (!comment.isDeleted()) {
+ flatList.add(new CommentResponseDto(
+ comment.getCommentId(),
+ comment.getUserId(),
+ comment.getContent(),
+ List.of() // ép thành cấp 2 -> không còn replies
+ ));
+ }
+
+ if (comment.getReplies() != null) {
+ for (PostComment child : comment.getReplies()) {
+ flatList.addAll(flattenReplies(child)); // đệ quy gom vào 1 list
+ }
+ }
+
+ return flatList;
+ }
+
+
+
+
+
+
+ @Transactional
+ public void updateComment(UpdateCommentDto requestDto, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ if(token == null || !token.startsWith("Bearer ")) {
+ throw new IllegalArgumentException("Authorization token is missing or invalid");
+ }
+ String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString(); // Assuming user ID is stored in the principal
+ if (userId == null) {
+ throw new IllegalArgumentException("User ID is required for updating comment");
+ }
+
+ PostComment comment = postCommentRepository.findById(requestDto.getCommentId())
+ .orElseThrow(() -> new RuntimeException("Comment not found"));
+
+ if (!comment.getUserId().equals(userId)) {
+ throw new RuntimeException("Not allowed to edit this comment");
+ }
+
+ comment.setContent(requestDto.getContent());
+ postCommentRepository.save(comment);
+ }
+
+ @Transactional
+ public void deleteComment(String commentId, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ if (token == null || !token.startsWith("Bearer ")) {
+ throw new IllegalArgumentException("Authorization token is missing or invalid");
+ }
+ String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString();
+ if (userId == null) {
+ throw new IllegalArgumentException("User ID is required for deleting comment");
+ }
+
+ PostComment comment = postCommentRepository.findById(commentId)
+ .orElseThrow(() -> new RuntimeException("Comment not found"));
+
+ if (!comment.isDeleted()) {
+ comment.markDeleted(userId);
+ postCommentRepository.save(comment);
+ }
+ }
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java b/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java
new file mode 100644
index 00000000..60223be7
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/PostReactionService.java
@@ -0,0 +1,97 @@
+package com.codecampus.post.service;
+
+import com.codecampus.post.config.CustomJwtDecoder;
+import com.codecampus.post.dto.request.PostReactionRequestDto;
+import com.codecampus.post.entity.Post;
+import com.codecampus.post.entity.PostReaction;
+import com.codecampus.post.repository.PostReactionRepository;
+import com.codecampus.post.repository.PostRepository;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.transaction.Transactional;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+
+@Service
+@RequiredArgsConstructor
+public class PostReactionService {
+ private final PostReactionRepository postReactionRepository;
+ private final PostRepository postRepository;
+ private final CustomJwtDecoder customJwtDecoder;
+
+ @Transactional
+ public void toggleReaction(PostReactionRequestDto requestDto, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ if (token == null || !token.startsWith("Bearer ")) {
+ throw new IllegalArgumentException("Authorization token is missing or invalid");
+ }
+
+ String userId = customJwtDecoder.decode(token.substring(7))
+ .getClaims()
+ .get("userId")
+ .toString();
+
+ if (userId == null) {
+ throw new IllegalArgumentException("User ID is required for post reaction");
+ }
+
+ // Kiểm tra reaction đã tồn tại chưa
+ Optional existingReaction = (requestDto.getCommentId() != null)
+ ? postReactionRepository.findByPost_PostIdAndUserIdAndCommentId(
+ requestDto.getPostId(), userId, requestDto.getCommentId())
+ : postReactionRepository.findByPost_PostIdAndUserId(
+ requestDto.getPostId(), userId);
+
+ if (existingReaction.isPresent()) {
+ PostReaction reaction = existingReaction.get();
+
+ if (reaction.getEmojiType().equals(requestDto.getReactionType())) {
+ // Nếu user bấm lại cùng loại reaction -> xóa
+ postReactionRepository.delete(reaction);
+ return;
+ } else {
+ // Nếu user chọn reaction khác -> update
+ reaction.setEmojiType(requestDto.getReactionType());
+ postReactionRepository.save(reaction);
+ return;
+ }
+ }
+
+ //chưa có reaction nào -> tạo mới
+ Post post = postRepository.findById(requestDto.getPostId())
+ .orElseThrow(() -> new RuntimeException("Post not found"));
+
+ PostReaction newReaction = new PostReaction();
+ newReaction.setPost(post);
+ newReaction.setUserId(userId);
+ newReaction.setCommentId(requestDto.getCommentId());
+ newReaction.setEmojiType(requestDto.getReactionType());
+
+ postReactionRepository.save(newReaction);
+ }
+
+ @Transactional
+ public Map getReactionCount(String postId, String commentId) {
+ long upvotes;
+ long downvotes;
+
+ if (commentId == null) {
+ // Reaction cho post
+ upvotes = postReactionRepository.countByPost_PostIdAndEmojiType(postId, "upvote");
+ downvotes = postReactionRepository.countByPost_PostIdAndEmojiType(postId, "downvote");
+ } else {
+ // Reaction cho comment trong post
+ upvotes = postReactionRepository.countByPost_PostIdAndCommentIdAndEmojiType(postId, commentId, "upvote");
+ downvotes = postReactionRepository.countByPost_PostIdAndCommentIdAndEmojiType(postId, commentId, "downvote");
+ }
+
+ Map result = new HashMap<>();
+ result.put("upvote", upvotes);
+ result.put("downvote", downvotes);
+ return result;
+ }
+
+}
diff --git a/post-service/src/main/java/com/codecampus/post/service/PostService.java b/post-service/src/main/java/com/codecampus/post/service/PostService.java
new file mode 100644
index 00000000..430a4186
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/service/PostService.java
@@ -0,0 +1,197 @@
+package com.codecampus.post.service;
+
+
+import com.codecampus.post.Mapper.PostMapper;
+import com.codecampus.post.config.CustomJwtDecoder;
+import com.codecampus.post.dto.common.PageRequestDto;
+import com.codecampus.post.dto.common.PageResponse;
+import com.codecampus.post.dto.request.AddFileDocumentDto;
+import com.codecampus.post.dto.request.PostRequestDto;
+import com.codecampus.post.dto.response.AddFileResponseDto;
+import com.codecampus.post.entity.Post;
+import com.codecampus.post.exception.AppException;
+import com.codecampus.post.exception.ErrorCode;
+import com.codecampus.post.repository.PostRepository;
+import com.codecampus.post.service.FeignConfig.FileServiceClient;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.AllArgsConstructor;
+import lombok.RequiredArgsConstructor;
+import org.springframework.context.annotation.Lazy;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+import org.springframework.http.MediaType;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.multipart.MultipartFile;
+
+import javax.imageio.ImageIO;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.http.HttpHeaders;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class PostService {
+ private final CustomJwtDecoder customJwtDecoder;
+ private final PostRepository postRepository;
+ private final PostMapper postMapper;
+ private final FileServiceClient fileServiceClient;
+
+ public PageResponse getAllAccessiblePosts(HttpServletRequest request, PageRequestDto pageRequestDto) {
+ String token = request.getHeader("Authorization");
+ String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString();
+ Pageable pageable = PageRequest.of(pageRequestDto.getPage(), pageRequestDto.getSize(), Sort.by("createdAt").descending());
+ Page postPage = postRepository.findAllVisiblePosts(userId, pageable);
+
+ return PageResponse.builder()
+ .currentPage(postPage.getNumber())
+ .totalPages(postPage.getTotalPages())
+ .pageSize(postPage.getSize())
+ .totalElements(postPage.getTotalElements())
+ .data(postPage.getContent())
+ .build();
+ }
+
+ public PageResponse> SeachPosts(String searchText, HttpServletRequest request, PageRequestDto pageRequestDto) {
+ String token = request.getHeader("Authorization");
+ String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString();
+ Pageable pageable = PageRequest.of(pageRequestDto.getPage(), pageRequestDto.getSize(), Sort.by("created_at").descending());
+ Page postPage = postRepository.searchVisiblePosts(searchText, userId, pageable);
+
+ return PageResponse.builder()
+ .currentPage(postPage.getNumber())
+ .totalPages(postPage.getTotalPages())
+ .pageSize(postPage.getSize())
+ .totalElements(postPage.getTotalElements())
+ .data(postPage.getContent())
+ .build();
+ }
+
+// public Optional getPostByIdIfAccessible(String postId, HttpServletRequest request) {
+// String token = request.getHeader("Authorization");
+// String userId = customJwtDecoder.decode(token.substring(7)).getClaims().get("userId").toString();
+// return postRepository.findAccessiblePostById(postId, userId)
+// .filter(post -> !post.isDeleted())
+// .map(postMapper::toDto);
+// }
+
+ public void createPost(PostRequestDto postRequestDto, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ String userId = customJwtDecoder.decode(token.substring(7))
+ .getClaims()
+ .get("userId")
+ .toString();
+
+ List fileUrls = Collections.emptyList();
+
+ var fileDoc = postRequestDto.getFileDocument();
+ if (fileDoc != null && fileDoc.getFile() != null && !fileDoc.getFile().isEmpty()) {
+
+ MultipartFile file = fileDoc.getFile();
+
+ // Kiểm tra file được tải lên có phải ảnh thật hay không
+ if (!isRealImage(file)) {
+ throw new AppException(ErrorCode.INVALID_FILE_TYPE);
+ }
+
+ // Upload file sau khi xác thực
+ AddFileResponseDto response = fileServiceClient.uploadFile(fileDoc);
+
+ // Nếu API C# trả result là String URL
+ fileUrls = Optional.ofNullable(response)
+ .map(AddFileResponseDto::getResult)
+ .map(List::of)
+ .orElse(Collections.emptyList());
+ }
+
+ Post post = postMapper.toEntity(postRequestDto);
+ post.setImagesUrls(fileUrls);
+ post.setUserId(userId);
+
+ postRepository.save(post);
+ }
+
+ public void updatePost(PostRequestDto postRequestDto, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ String userId = customJwtDecoder.decode(token.substring(7))
+ .getClaims()
+ .get("userId").toString();
+ String username = customJwtDecoder.decode(token.substring(7))
+ .getClaims()
+ .get("username").toString();
+
+ Optional optionalPost = postRepository.findById(postRequestDto.getPostId());
+ if (optionalPost.isEmpty()) throw new AppException(ErrorCode.POST_NOT_FOUND);
+
+ Post existingPost = optionalPost.get();
+
+ // Chỉ cho phép người tạo hoặc admin chỉnh sửa
+ if (!existingPost.getUserId().equals(userId) && !"admin".equals(username)) {
+ throw new AppException(ErrorCode.UNAUTHORIZED);
+ }
+
+ // Danh sách ảnh sau khi chỉnh sửa
+ List updatedImageUrls = new ArrayList<>();
+
+ // 1. Giữ lại ảnh cũ mà người dùng vẫn muốn giữ
+ if (postRequestDto.getOldImgesUrls() != null && !postRequestDto.getOldImgesUrls().isEmpty()) {
+ updatedImageUrls.addAll(
+ existingPost.getImagesUrls().stream()
+ .filter(url -> postRequestDto.getOldImgesUrls().contains(url))
+ .toList()
+ );
+ }
+
+ // 2. Nếu có ảnh mới thì kiểm tra và upload
+ if (postRequestDto.getFileDocument() != null &&
+ postRequestDto.getFileDocument().getFile() != null &&
+ !postRequestDto.getFileDocument().getFile().isEmpty()) {
+
+ MultipartFile file = postRequestDto.getFileDocument().getFile();
+ if (!isRealImage(file)) {
+ throw new AppException(ErrorCode.INVALID_FILE_TYPE);
+ }
+
+ // Upload ảnh mới
+ AddFileResponseDto response = fileServiceClient.uploadFile(postRequestDto.getFileDocument());
+ List newFileUrls = Optional.ofNullable(response)
+ .map(AddFileResponseDto::getResult)
+ .map(List::of)
+ .orElse(Collections.emptyList());
+
+ updatedImageUrls.addAll(newFileUrls);
+ }
+
+ // 3. Cập nhật các field khác
+ postMapper.updateEntityFromDto(postRequestDto, existingPost);
+ existingPost.setImagesUrls(updatedImageUrls);
+
+ postRepository.save(existingPost);
+ }
+
+
+ public void deletePost(String postId, HttpServletRequest request) {
+ String token = request.getHeader("Authorization");
+ String deletedBy = customJwtDecoder.decode(token.substring(7)).getClaims().get("username").toString();
+ Post post = postRepository.findById(postId)
+ .orElseThrow(() -> new IllegalArgumentException("Post not found with id: " + postId));
+ post.markDeleted(deletedBy);
+ postRepository.save(post);
+ }
+
+ public boolean isRealImage(MultipartFile file) {
+ try (InputStream input = file.getInputStream()) {
+ return ImageIO.read(input) != null;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+
+}
+
diff --git a/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java b/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java
new file mode 100644
index 00000000..d7b12b6a
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/utils/ConvertUtils.java
@@ -0,0 +1,80 @@
+package com.codecampus.post.utils;
+
+import org.mapstruct.Named;
+
+import java.time.Instant;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+
+/**
+ * Tiện ích chuyển đổi giữa định dạng chuỗi ngày "dd/MM/yyyy"
+ * và đối tượng {@link Instant}.
+ *
+ *
Cung cấp các phương thức:
+ *
+ *
{@link #parseDdMmYyyyToInstant(String)}: Chuyển chuỗi ngày sang {@code Instant} tại đầu ngày (00:00 UTC).
+ *
{@link #formatInstantToDdMmYyyyUtc(Instant)}: Định dạng {@code Instant} sang chuỗi ngày theo UTC.
+ *
{@link #formatInstantToDdMmYyyyLocal(Instant)}: Định dạng {@code Instant} sang chuỗi ngày theo múi giờ hệ thống.
+ *
+ *
+ */
+public class ConvertUtils {
+ // Định dạng dd/MM/yyyy
+ private static final DateTimeFormatter DMY_FORMATTER =
+ DateTimeFormatter.ofPattern("dd/MM/yyyy");
+
+ /**
+ * Chuyển chuỗi ngày có định dạng dd/MM/yyyy sang Instant (đầu ngày).
+ *
+ * @param dateStr chuỗi ngày theo định dạng "dd/MM/yyyy", ví dụ "31/12/2024"
+ * @return Instant tương ứng (thời điểm bắt đầu ngày đó) theo múi giờ hệ thống
+ */
+ @Named("DdMmYyyyToInstant")
+ public static Instant parseDdMmYyyyToInstant(String dateStr) {
+ if (dateStr == null || dateStr.isBlank()) {
+ return null;
+ }
+
+ // Parse thành LocalDate
+ LocalDate localDate = LocalDate.parse(dateStr, DMY_FORMATTER);
+ // Chuyển sang Instant tại thời điểm 00:00 của ngày, theo múi giờ hệ thống
+ return localDate.atStartOfDay(ZoneOffset.UTC).toInstant();
+ }
+
+ /**
+ * Chuyển Instant về chuỗi ngày "dd/MM/yyyy" tính theo UTC.
+ *
+ * @param instant thời điểm cần format
+ * @return chuỗi ngày dạng "dd/MM/yyyy"
+ */
+ @Named("instantToDdMmYyyyUTC")
+ public static String formatInstantToDdMmYyyyUtc(Instant instant) {
+ if (instant == null) {
+ return null;
+ }
+
+ return instant
+ .atZone(ZoneOffset.UTC)
+ .toLocalDate()
+ .format(DMY_FORMATTER);
+ }
+
+ /**
+ * Chuyển Instant về chuỗi ngày "dd/MM/yyyy" tính theo múi giờ hệ thống.
+ *
+ * @param instant thời điểm cần format
+ * @return chuỗi ngày dạng "dd/MM/yyyy"
+ */
+ public static String formatInstantToDdMmYyyyLocal(Instant instant) {
+ if (instant == null) {
+ return null;
+ }
+
+ return instant
+ .atZone(ZoneId.systemDefault())
+ .toLocalDate()
+ .format(DMY_FORMATTER);
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java b/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java
new file mode 100644
index 00000000..d78f18b3
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/utils/DateTimeFormatter.java
@@ -0,0 +1,113 @@
+package com.codecampus.post.utils;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.function.Function;
+
+/**
+ * Tiện ích định dạng ngày giờ (DateTime) cho các thông báo hiển thị tương đối.
+ *
+ *
Cung cấp phương thức format(Instant) để trả về chuỗi mô tả khoảng thời gian đã trôi qua:
+ *
+ *
Nếu dưới 60 giây, hiển thị "x second(s) ago".
+ *
Nếu dưới 60 phút, hiển thị "x minute(s) ago".
+ *
Nếu dưới 24 giờ, hiển thị "x hour(s) ago".
+ *
Nếu lớn hơn hoặc bằng 24 giờ, hiển thị ngày theo định dạng ISO (YYYY-MM-DD).
+ *
+ *
+ */
+@Component
+public class DateTimeFormatter {
+ /**
+ * Bản đồ chiến lược format dựa trên ngưỡng thời gian (giây).
+ * Key: số giây tối đa, Value: hàm format tương ứng.
+ */
+ Map> strategyMap = new LinkedHashMap<>();
+
+ /**
+ * Khởi tạo bản đồ chiến lược với các ngưỡng:
+ *
+ *
60 giây → formatInSeconds()
+ *
3600 giây (60 phút) → formatInMinutes()
+ *
86400 giây (24 giờ) → formatInHours()
+ *
Long.MAX_VALUE → formatInDate()
+ *
+ */
+ public DateTimeFormatter() {
+ strategyMap.put(60L, this::formatInSeconds);
+ strategyMap.put(3600L, this::formatInMinutes);
+ strategyMap.put(86400L, this::formatInHours);
+ strategyMap.put(Long.MAX_VALUE, this::formatInDate);
+ }
+
+ /**
+ * Định dạng Instant thành chuỗi mô tả khoảng thời gian đã trôi qua.
+ *
+ * @param instant thời điểm cần định dạng
+ * @return chuỗi mô tả thời gian (ví dụ "5 minute(s) ago" hoặc "2025-06-14")
+ */
+ public String format(Instant instant) {
+ long elapseSeconds = ChronoUnit.SECONDS.between(instant, Instant.now());
+
+ var strategy = strategyMap.entrySet()
+ .stream()
+ .filter(longFunctionEntry -> elapseSeconds <
+ longFunctionEntry.getKey())
+ .findFirst().get();
+ return strategy.getValue().apply(instant);
+ }
+
+ /**
+ * Format khoảng thời gian tính theo giây.
+ *
+ * @param instant thời điểm cần so sánh
+ * @return "x second(s) ago"
+ */
+ private String formatInSeconds(Instant instant) {
+ long elapseSeconds = ChronoUnit.SECONDS.between(instant, Instant.now());
+ return String.format("%s second(s) ago", elapseSeconds);
+ }
+
+ /**
+ * Format khoảng thời gian tính theo phút.
+ *
+ * @param instant thời điểm cần so sánh
+ * @return "x minute(s) ago"
+ */
+ private String formatInMinutes(Instant instant) {
+ long elapseMinutes = ChronoUnit.MINUTES.between(instant, Instant.now());
+ return String.format("%s minute(s) ago", elapseMinutes);
+ }
+
+ /**
+ * Format khoảng thời gian tính theo giờ.
+ *
+ * @param instant thời điểm cần so sánh
+ * @return "x hour(s) ago"
+ */
+ private String formatInHours(Instant instant) {
+ long elapseHours = ChronoUnit.HOURS.between(instant, Instant.now());
+ return String.format("%s hour(s) ago", elapseHours);
+ }
+
+ /**
+ * Khi khoảng thời gian đã quá 24 giờ, format về chuỗi ngày ISO (YYYY-MM-DD).
+ *
+ * @param instant thời điểm cần định dạng
+ * @return chuỗi ngày ISO
+ */
+ private String formatInDate(Instant instant) {
+ LocalDateTime localDateTime =
+ instant.atZone(ZoneId.systemDefault()).toLocalDateTime();
+ java.time.format.DateTimeFormatter dateTimeFormatter =
+ java.time.format.DateTimeFormatter.ISO_DATE;
+
+ return localDateTime.format(dateTimeFormatter);
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java b/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java
new file mode 100644
index 00000000..009389f8
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/utils/PageResponseUtils.java
@@ -0,0 +1,28 @@
+package com.codecampus.post.utils;
+
+import com.codecampus.post.dto.common.PageResponse;
+import org.springframework.data.domain.Page;
+
+public class PageResponseUtils {
+ private PageResponseUtils() {
+ }
+
+ /**
+ * Chuyển một {@link Page}<T> thành {@link PageResponse}<T>.
+ *
+ * @param pageData đối tượng Spring Data Page chứa dữ liệu và thông tin phân trang
+ * @param currentPage số trang hiện tại (bắt đầu từ 1)
+ * @param kiểu của phần tử trong trang
+ * @return {@link PageResponse}<T> tương ứng
+ */
+ public static PageResponse toPageResponse(Page pageData,
+ int currentPage) {
+ return PageResponse.builder()
+ .currentPage(currentPage)
+ .pageSize(pageData.getSize())
+ .totalPages(pageData.getTotalPages())
+ .totalElements(pageData.getTotalElements())
+ .data(pageData.getContent().stream().toList())
+ .build();
+ }
+}
diff --git a/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java b/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java
new file mode 100644
index 00000000..7df2c1d4
--- /dev/null
+++ b/post-service/src/main/java/com/codecampus/post/utils/SecurityUtils.java
@@ -0,0 +1,20 @@
+package com.codecampus.post.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+/**
+ * Tiện ích hỗ trợ lấy thông tin người dùng hiện đang đăng nhập
+ * từ ngữ cảnh bảo mật của Spring Security.
+ */
+@Slf4j
+public class SecurityUtils {
+ /**
+ * Lấy ID của người dùng đã đăng nhập.
+ *
+ * @return chuỗi tên đăng nhập hoặc null nếu chưa xác thực
+ */
+ public static String getMyUserId() {
+ return SecurityContextHolder.getContext().getAuthentication().getName();
+ }
+}
diff --git a/post-service/src/main/resources/application.yml b/post-service/src/main/resources/application.yml
new file mode 100644
index 00000000..724ae936
--- /dev/null
+++ b/post-service/src/main/resources/application.yml
@@ -0,0 +1,31 @@
+server:
+ port: 8090
+
+ servlet:
+ context-path: /post
+
+file:
+ service:
+ file-url: http://localhost:8082
+
+spring:
+ application:
+ name: post-service
+ datasource:
+ url: jdbc:postgresql://localhost:5439/post_db
+ username: postgres_post
+ password: dinhanst2832004
+ driver-class-name: org.postgresql.Driver
+
+ jpa:
+ hibernate:
+ ddl-auto: update
+ properties:
+ hibernate:
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ show-sql: true
+
+ cloud:
+ openfeign:
+ multipart:
+ enabled: true
diff --git a/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java b/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java
new file mode 100644
index 00000000..11f7ce3f
--- /dev/null
+++ b/post-service/src/test/java/com/codecampus/post/PostServiceApplicationTests.java
@@ -0,0 +1,13 @@
+package com.codecampus.post;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.test.context.SpringBootTest;
+
+@SpringBootTest
+class PostServiceApplicationTests {
+
+ @Test
+ void contextLoads() {
+ }
+
+}
diff --git a/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java b/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java
index 13cde93c..7e9e2e97 100644
--- a/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java
+++ b/profile-service/src/main/java/com/codecampus/profile/repository/OrgRepository.java
@@ -15,6 +15,8 @@ public interface OrgRepository
@Query(value = """
MATCH (o:Organization {orgId:$orgId})-[a:ASSIGNED_ORG_EXERCISE]->(e:Exercise)
RETURN a, e ORDER BY e.title
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (o:Organization {orgId:$orgId})-[a:ASSIGNED_ORG_EXERCISE]->(:Exercise)
diff --git a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java
index 63b881c7..aaf8fb5e 100644
--- a/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java
+++ b/profile-service/src/main/java/com/codecampus/profile/repository/UserProfileRepository.java
@@ -47,6 +47,8 @@ public interface UserProfileRepository
MATCH (u)-[completed:COMPLETED_EXERCISE]->(e:Exercise)
RETURN completed, e
ORDER BY completed.completedAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -64,6 +66,8 @@ Page findCompletedExercises(
MATCH (u)-[saved:SAVED_EXERCISE]->(e:Exercise)
RETURN saved, e
ORDER BY saved.saveAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -81,6 +85,8 @@ Page findSavedExercises(
MATCH (u)-[created:CREATED_EXERCISE]->(e:Exercise)
RETURN created, e
ORDER BY created.id DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -100,6 +106,8 @@ Page findCreatedExercises(
MATCH (u)-[cs:CONTEST_STATUS]->(c:Contest)
RETURN cs, c
ORDER BY cs.updatedAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -119,6 +127,8 @@ Page findContestStatuses(
MATCH (u)-[sp:SAVED_POST]->(p:Post)
RETURN sp, p
ORDER BY sp.saveAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -136,6 +146,8 @@ Page findSavedPosts(
MATCH (u)-[r:REACTION]->(p:Post)
RETURN r, p
ORDER BY r.at DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -153,6 +165,8 @@ Page findReactions(
MATCH (u)-[rp:REPORTED_POST]->(p:Post)
RETURN rp, p
ORDER BY rp.reportedAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -172,6 +186,8 @@ Page findReportedPosts(
MATCH (u)-[:HAS_ACTIVITY]->(a:ActivityWeek)
RETURN a
ORDER BY a.weekStart DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -192,6 +208,8 @@ Page findActivityWeek(
WHERE target.deletedAt IS NULL
RETURN f, target
ORDER BY f.since DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (me:User {userId:$userId})
@@ -211,6 +229,8 @@ Page findFollowings(
WHERE src.deletedAt IS NULL
RETURN f, src
ORDER BY f.since DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (me:User {userId:$userId})
@@ -230,6 +250,8 @@ Page findFollowers(
WHERE target.deletedAt IS NULL
RETURN b, target
ORDER BY b.since DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (me:User {userId:$userId})
@@ -250,6 +272,8 @@ Page findBlocked(
MATCH (u)-[m:MEMBER_ORG]->(o:Organization)
RETURN m, o
ORDER BY m.joinAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -268,6 +292,8 @@ Page findMemberOrgs(
WHERE m.memberRole = $role
RETURN m, o
ORDER BY m.joinAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -287,6 +313,8 @@ Page findMemberOrgsByRole(
MATCH (u)-[c:CREATED_ORG]->(o:Organization)
RETURN c, o
ORDER BY c.createdAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -306,6 +334,8 @@ Page findCreatedOrgs(
MATCH (u)-[s:SUBSCRIBED_TO]->(p:Package)
RETURN s, p
ORDER BY s.start DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})
@@ -326,6 +356,8 @@ Page findSubscriptions(
WHERE f.type = $type
RETURN sr, f
ORDER BY sr.saveAt DESC
+ SKIP $skip
+ LIMIT $limit
""",
countQuery = """
MATCH (u:User {userId:$userId})