diff --git a/.env b/.env index e7760ce..847db4a 100644 --- a/.env +++ b/.env @@ -2,10 +2,12 @@ # Replace with your actual OAuth provider credentials # Google OAuth2 -GOOGLE_CLIENT_ID=your-google-client-id -GOOGLE_CLIENT_SECRET=your-google-client-secret +# GOOGLE_CLIENT_ID=your-google-client-id +# GOOGLE_CLIENT_SECRET=your-google-client-secret +GOOGLE_CLIENT_ID=760575932383-1ah7nvt9vi02pr5r20h5nm5g8p1pl87j.apps.googleusercontent.com +GOOGLE_CLIENT_SECRET=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 + REDIRECT_URL=http://localhost/api/login/oauth2/code -GOOGLE_REDIRECT_URI=http://localhost:8080/login/oauth2/code/{registrationId} # Facebook OAuth2 FACEBOOK_CLIENT_ID=your-facebook-app-id-here FACEBOOK_CLIENT_SECRET=your-facebook-app-secret-here @@ -24,21 +26,28 @@ APPLE_CLIENT_SECRET=your-apple-client-secret-jwt-here CONTENT_MODERATION_WEBHOOK_URL=https://inscriptions.cdacb.in/n8n/webhook/content-moderation CONTENT_MODERATION_INSECURE_SSL=true - CONTENT_MODERATION_SAFE_THRESHOLD=0.7 CONTENT_MODERATION_CONNECT_TIMEOUT_MS=5000 CONTENT_MODERATION_READ_TIMEOUT_MS=10000 -ADMIN_APPROVAL_INTERNAL_EMAIL=your-internal-approver@example.com -APP_CORS_URL=http://localhost:3000 -APP_BACKEND_URL=http://localhost:8080 -APP_FRONTEND_OAUTH_CALLBACK_URL=http://localhost:8080/api/v1/noauth/check -APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/api/v1/noauth/check + + +ADMIN_APPROVAL_INTERNAL_EMAIL=bavithbabu25@gmail.com +APP_CORS_URL=https://inscriptions.cdacb.in +APP_COOKIE_DOMAIN=inscriptions.cdacb.in +APP_FRONTEND_OAUTH_CALLBACK_URL=https://inscriptions.cdacb.in/oauth/callback +APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL=https://inscriptions.cdacb.in/admin/oauth/callback + +APP_BACKEND_URL=https://inscriptions.cdacb.in/api +APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=https://inscriptions.cdacb.in/admin/approval-result + +# APP_BACKEND_URL=http://localhost:8080 +# APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL=http://localhost:8080/oauth2/admin/approve # Spring Mail spring.mail.host=smtp.gmail.com spring.mail.port=587 -spring.mail.username=your-email@example.com -spring.mail.password=your-app-password +spring.mail.username=bavithbabu25@gmail.com +spring.mail.password=ugfj wcfo cbah hfau spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.starttls.enable=true diff --git a/pom.xml b/pom.xml index a6a6311..c95efaf 100644 --- a/pom.xml +++ b/pom.xml @@ -42,6 +42,13 @@ spring-boot-starter-actuator + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.8.9 + + org.springframework.boot diff --git a/rough.tsx b/rough.tsx new file mode 100644 index 0000000..bf9abf3 --- /dev/null +++ b/rough.tsx @@ -0,0 +1,170 @@ +import React from "react"; +import { useLocation } from "react-router-dom"; +import { setPostLoginRedirect } from "@/utils/postLoginRedirect"; +// import { GoogleOAuthProvider, GoogleLogin } from "@react-oauth/google"; +// import { jwtDecode } from "jwt-decode"; +// import { redirect } from "react-router-dom"; + +const redirectURL = window._env_?.VITE_REDIRECT_URL + || import.meta.env.VITE_REDIRECT_URL + || "/api/oauth2/login"; +const adminLoginRedirectURL = window._env_?.VITE_ADMIN_LOGIN_REDIRECT_URL + || import.meta.env.VITE_ADMIN_LOGIN_REDIRECT_URL + || "/api/oauth2/admin/login"; +const adminRegisterRedirectURL = window._env_?.VITE_ADMIN_REGISTER_REDIRECT_URL + || import.meta.env.VITE_ADMIN_REGISTER_REDIRECT_URL + || "/api/oauth2/admin/register"; +const OAUTH_CALLBACK_GUARD_KEY = "auth:oauth-callback-processed"; + +const AuthPage: React.FC = () => { + const location = useLocation(); + + const getSafeRedirectPath = () => { + const next = new URLSearchParams(location.search).get("next") || ""; + if (next.startsWith("/") && !next.startsWith("//")) { + return next; + } + + const from = location.state && typeof (location.state as { from?: unknown }).from === "string" + ? (location.state as { from: string }).from + : ""; + + if (from.startsWith("/") && !from.startsWith("//")) { + return from; + } + + return null; + }; + + // const handleLoginSuccess = (credentialResponse: any) => { + // if (credentialResponse.credential) { + // const decoded: any = jwtDecode(credentialResponse.credential); + // console.log("User Info:", decoded); + // // You can send credentialResponse.credential to your backend for verification + // } + // }; + + // const handleLoginFailure = () => { + // console.error("Login Failed"); + // }; + const prepareOAuthRedirect = () => { + const redirectPath = getSafeRedirectPath(); + + if (redirectPath) { + setPostLoginRedirect(redirectPath); + } + + // Reset callback guard before initiating a new OAuth round-trip. + sessionStorage.removeItem(OAUTH_CALLBACK_GUARD_KEY); + }; + + const handleGoogleLogin = () => { + prepareOAuthRedirect(); + window.location.href = redirectURL; + } + + const handleAdminLogin = () => { + prepareOAuthRedirect(); + window.location.href = adminLoginRedirectURL; + }; + + const handleAdminRegister = () => { + prepareOAuthRedirect(); + window.location.href = adminRegisterRedirectURL; + }; + + return ( + + + + Welcome Back 👋 + + + Sign in or create an account with Google + + {/* + + */} + + + {/* Google Icon */} + + + + + + + + {( + + {/* */} + Continue With Google + + )} + + + + + + Continue As Admin + + + + Request Admin Access + + + + + By continuing, you agree to our{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + + + + ); +}; + +export default AuthPage; diff --git a/rough2.tsx b/rough2.tsx new file mode 100644 index 0000000..48dcb11 --- /dev/null +++ b/rough2.tsx @@ -0,0 +1,121 @@ +import { useContext, useEffect } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { authClient } from "@/utils/http/clients/authClient.client"; +import AuthContext from "@/context/AuthContext"; +import cdacRoundLogo from '@/assets/cdacroundlogo.png'; +import { getPostLoginRedirect } from "@/utils/postLoginRedirect"; + +const MAX_REFRESH_RETRIES = 3; +const RETRY_DELAY_MS = 700; +const OAUTH_CALLBACK_GUARD_KEY = "auth:oauth-callback-processed"; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const OAuthCallback = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const { loginSuccess } = useContext(AuthContext); + + useEffect(() => { + // React StrictMode runs effects twice in development. + // Guard prevents a second callback pass from overriding the first successful redirect. + if (sessionStorage.getItem(OAUTH_CALLBACK_GUARD_KEY) === "1") { + return; + } + sessionStorage.setItem(OAUTH_CALLBACK_GUARD_KEY, "1"); + + const navigateToLoginWithNext = () => { + sessionStorage.removeItem(OAUTH_CALLBACK_GUARD_KEY); + const next = getPostLoginRedirect(); + if (next) { + navigate(`/login?next=${encodeURIComponent(next)}`, { replace: true }); + } else { + navigate("/login", { replace: true }); + } + }; + + const completeLogin = async () => { + try { + const status = searchParams.get("status"); + const flow = searchParams.get("flow"); + + if (status === "pending" && flow === "admin_register") { + navigate("/login?admin_request=pending", { replace: true }); + return; + } + + if (status === "denied" && flow === "admin_login") { + navigate("/login?admin_access=denied", { replace: true }); + return; + } + + if (status && status !== "success") { + throw new Error(`OAuth callback returned unsupported status: ${status}`); + } + + let accessToken: string | null = null; + let lastError: unknown = null; + + for (let attempt = 1; attempt <= MAX_REFRESH_RETRIES; attempt++) { + try { + console.log(`OAuthCallback: refresh attempt ${attempt}/${MAX_REFRESH_RETRIES}`); + const res = await authClient.post("/oauth2/authenticated/refresh-token"); + console.log("OAuthCallback: refresh response:", res && res.data); + + accessToken = res?.data?.data?.accessToken || res?.data?.auth_token || res?.data?.token || null; + console.log("OAuthCallback: computed accessToken:", accessToken); + + if (accessToken) break; + lastError = new Error("No access token found in refresh response"); + } catch (error) { + lastError = error; + console.warn(`OAuthCallback: refresh attempt ${attempt} failed`, { + message: (error as any)?.message, + status: (error as any)?.response?.status, + }); + } + + if (attempt < MAX_REFRESH_RETRIES) { + await wait(RETRY_DELAY_MS * attempt); + } + } + + if (accessToken) { + loginSuccess(accessToken); + + const savedRedirect = getPostLoginRedirect(); + + if (savedRedirect && savedRedirect.startsWith("/") && !savedRedirect.startsWith("//")) { + navigate(savedRedirect, { replace: true }); + } else { + navigate("/home", { replace: true }); + } + } else { + throw lastError || new Error("OAuth callback failed without an access token"); + } + } catch (error) { + console.error("Error completing OAuth login:", { + message: (error as any)?.message, + response: (error as any)?.response?.data, + status: (error as any)?.response?.status, + }); + navigateToLoginWithNext(); + } + }; + + completeLogin(); + }, [loginSuccess, navigate, searchParams]); + + return ( + + + {/* */} + + Logging in... + + + ) + +}; + +export default OAuthCallback; diff --git a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java index af6cdd8..f59cc7e 100644 --- a/src/main/java/com/cadac/stone_inscription/OAuthDemo.java +++ b/src/main/java/com/cadac/stone_inscription/OAuthDemo.java @@ -4,50 +4,62 @@ import org.springframework.security.access.annotation.Secured; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; - -@RestController -@RequestMapping("/api/v1") -public class OAuthDemo { - - @GetMapping("/") - public String getMethodName(@RequestParam String param) { - return "hello "; - } - - @Secured( "user" ) - @GetMapping("/fail") - public String getfail() { - return "hello fail "; - } - - @GetMapping("/noauth/check") - public String noAuth() { - return "This endpoint does not require authentication."; - } - - // Direct Facebook login endpoint - @GetMapping("/auth/facebook") - public String facebookLogin() { - return "redirect:/oauth2/authorization/facebook"; - } - - @Secured({ "admin" }) - @GetMapping("/auth") - public String auth() { - return "This endpoint requires authentication."; - } - - @GetMapping("/auth/token") - public String authToken() { - return "Authentication successful. You can now access secured endpoints."; - } - - @Secured({ "user", "admin" }) - @GetMapping("/authrole") - public String authRole() { - return "This endpoint requires authentication with role."; - } +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; + +@RestController +@RequestMapping("/api/v1") +@Tag(name = "Authentication", description = "OAuth smoke-test and role-check endpoints.") +public class OAuthDemo { + + @GetMapping("/") + @Operation(summary = "API smoke test", description = "Returns a minimal success message for connectivity checks.") + public String getMethodName(@Parameter(description = "Echo parameter placeholder.", example = "ping") @RequestParam String param) { + return "hello "; + } + + @Secured( "user" ) + @GetMapping("/fail") + @Operation(summary = "User role check", description = "Requires a user role and returns a simple role-check message.") + public String getfail() { + return "hello fail "; + } + + @GetMapping("/noauth/check") + @Operation(summary = "Public auth health check", description = "Confirms that public no-auth routes are reachable.") + public String noAuth() { + return "This endpoint does not require authentication."; + } + + // Direct Facebook login endpoint + @GetMapping("/auth/facebook") + @Operation(summary = "Facebook login redirect marker", description = "Legacy endpoint that returns the Facebook OAuth redirect target as text.") + public String facebookLogin() { + return "redirect:/oauth2/authorization/facebook"; + } + + @Secured({ "admin" }) + @GetMapping("/auth") + @Operation(summary = "Admin auth check", description = "Requires an admin role and returns a simple authenticated message.") + public String auth() { + return "This endpoint requires authentication."; + } + + @GetMapping("/auth/token") + @Operation(summary = "Token success marker", description = "Returns a text message used after successful authentication flows.") + public String authToken() { + return "Authentication successful. You can now access secured endpoints."; + } + + @Secured({ "user", "admin" }) + @GetMapping("/authrole") + @Operation(summary = "User or admin role check", description = "Requires either user or admin role.") + public String authRole() { + return "This endpoint requires authentication with role."; + } } diff --git a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java index 1d56e64..9aba208 100644 --- a/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java +++ b/src/main/java/com/cadac/stone_inscription/admin/service/AdminAccessServiceImpl.java @@ -33,7 +33,7 @@ public class AdminAccessServiceImpl implements AdminAccessService { @Value("${app.backend.url}") private String backendUrl; - @Value("${app.frontend.admin.approval-result-url:https://inscriptions.cdacb.in/admin/approval-result}") + @Value("${app.frontend.admin.approval-result-url}") private String approvalResultUrl; @Value("${admin.approval.token-validity-ms:86400000}") diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java new file mode 100644 index 0000000..3b06ed6 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/AccessTokenResponse.java @@ -0,0 +1,18 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AccessTokenResponse", description = "Access token payload returned after refresh-token rotation.") +public class AccessTokenResponse { + + @Schema(description = "New short-lived JWT access token.", example = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJuser@example.com\"") + private String accessToken; + + public String getAccessToken() { + return accessToken; + } + + public void setAccessToken(String accessToken) { + this.accessToken = accessToken; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java new file mode 100644 index 0000000..a255791 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiErrorResponse.java @@ -0,0 +1,44 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiErrorResponse", description = "Standard error body returned by API exception handlers.") +public class ApiErrorResponse { + + @Schema(description = "Human-readable error message.", example = "Invalid or missing authorization token") + @JsonProperty("error_message") + private String errorMessage; + + @Schema(description = "HTTP status reason or numeric status used by the handler.", example = "UNAUTHORIZED") + @JsonProperty("http_status") + private Object httpStatus; + + @Schema(description = "HTTP status code.", example = "401") + @JsonProperty("http_status_code") + private Integer httpStatusCode; + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public Integer getHttpStatusCode() { + return httpStatusCode; + } + + public void setHttpStatusCode(Integer httpStatusCode) { + this.httpStatusCode = httpStatusCode; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java new file mode 100644 index 0000000..783f6e4 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ApiSuccessResponse.java @@ -0,0 +1,42 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import com.fasterxml.jackson.annotation.JsonProperty; + +@Schema(name = "ApiSuccessResponse", description = "Standard success envelope returned by most JSON endpoints.") +public class ApiSuccessResponse { + + @Schema(description = "Operation result message.", example = "Profile fetched successfully") + private String message; + + @Schema(description = "HTTP status reason used by the response envelope.", example = "OK") + @JsonProperty("http-status") + private Object httpStatus; + + @Schema(description = "Endpoint-specific payload.") + private T data; + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public Object getHttpStatus() { + return httpStatus; + } + + public void setHttpStatus(Object httpStatus) { + this.httpStatus = httpStatus; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java new file mode 100644 index 0000000..705f3cf --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/DashboardCountsResponse.java @@ -0,0 +1,62 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "DashboardCountsResponse", description = "Public aggregate counters for the application dashboard.") +public class DashboardCountsResponse { + + @Schema(description = "Registered users.", example = "128") + private Integer totalUsers; + + @Schema(description = "Published posts.", example = "42") + private Integer totalPosts; + + @Schema(description = "Uploaded image objects.", example = "280") + private Integer totalImages; + + @Schema(description = "Posts with extracted geolocation metadata.", example = "31") + private Integer totalGeoTaggedPosts; + + @Schema(description = "Posts with an English translation available.", example = "17") + private Integer totalTranslations; + + public Integer getTotalUsers() { + return totalUsers; + } + + public void setTotalUsers(Integer totalUsers) { + this.totalUsers = totalUsers; + } + + public Integer getTotalPosts() { + return totalPosts; + } + + public void setTotalPosts(Integer totalPosts) { + this.totalPosts = totalPosts; + } + + public Integer getTotalImages() { + return totalImages; + } + + public void setTotalImages(Integer totalImages) { + this.totalImages = totalImages; + } + + public Integer getTotalGeoTaggedPosts() { + return totalGeoTaggedPosts; + } + + public void setTotalGeoTaggedPosts(Integer totalGeoTaggedPosts) { + this.totalGeoTaggedPosts = totalGeoTaggedPosts; + } + + public Integer getTotalTranslations() { + return totalTranslations; + } + + public void setTotalTranslations(Integer totalTranslations) { + this.totalTranslations = totalTranslations; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java new file mode 100644 index 0000000..c8e3398 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/api/dto/ReportQueuedResponse.java @@ -0,0 +1,51 @@ +package com.cadac.stone_inscription.api.dto; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "ReportQueuedResponse", description = "Payload returned after a report is accepted for asynchronous moderation.") +public class ReportQueuedResponse { + + @Schema(description = "Published report event identifier.", example = "7f8b7d33-16f2-4e84-9f54-8085a9e84791") + private String eventId; + + @Schema(description = "Identifier of the reported content or user.", example = "665f1df013ad4e18f6a11244") + private String targetId; + + @Schema(description = "Reported resource type.", example = "POST") + private String targetType; + + @Schema(description = "Queue processing state.", example = "QUEUED") + private String status; + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public String getTargetId() { + return targetId; + } + + public void setTargetId(String targetId) { + this.targetId = targetId; + } + + public String getTargetType() { + return targetType; + } + + public void setTargetType(String targetType) { + this.targetType = targetType; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java index bd27e36..4f720d7 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java +++ b/src/main/java/com/cadac/stone_inscription/auth/CustomOAuth2SuccessHandler.java @@ -1,15 +1,14 @@ -package com.cadac.stone_inscription.auth; - +package com.cadac.stone_inscription.auth; + import java.io.IOException; import java.time.Duration; import java.time.LocalDateTime; import java.util.List; import java.util.Map; - + import org.bson.types.ObjectId; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; @@ -22,17 +21,16 @@ import com.cadac.stone_inscription.auth.utill.GenrateRefreshToken; import com.cadac.stone_inscription.entity.User; import com.cadac.stone_inscription.entity.UserAuth; -import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.repository.UserAuthRepository; import com.cadac.stone_inscription.repository.UserRepository; - -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final UserRepository userRepository; @@ -41,23 +39,26 @@ public class CustomOAuth2SuccessHandler extends SimpleUrlAuthenticationSuccessHa private final AdminAccessService adminAccessService; private final OAuthFlowCookieService oAuthFlowCookieService; - @Value("${app.frontend.oauth.callback-url:https://inscriptions.cdacb.in/oauth/callback}") + @Value("${app.frontend.oauth.callback-url}") private String frontendCallbackUrl; + @Value("${app.frontend.oauth.admin-callback-url}") + private String frontendAdminCallbackUrl; + @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, - Authentication authentication - ) throws IOException, ServletException { - - OAuth2AuthenticationToken oauthToken = - (OAuth2AuthenticationToken) authentication; - - Map attributes = - oauthToken.getPrincipal().getAttributes(); - - String provider = oauthToken.getAuthorizedClientRegistrationId(); + Authentication authentication + ) throws IOException, ServletException { + + OAuth2AuthenticationToken oauthToken = + (OAuth2AuthenticationToken) authentication; + + Map attributes = + oauthToken.getPrincipal().getAttributes(); + + String provider = oauthToken.getAuthorizedClientRegistrationId(); String email = (String) attributes.get("email"); String name = (String) attributes.get("name"); String picture = (String) attributes.get("picture"); @@ -65,26 +66,27 @@ public void onAuthenticationSuccess( UserAuth userAuth = findOrCreateUser(email, name, picture, provider); oAuthFlowCookieService.clearFlow(response); - - switch (flowType) { - case ADMIN_REGISTER -> { - adminAccessService.createOrRefreshPendingRequest(userAuth, name, provider); - redirect(request, response, "pending", "admin_register"); - } - case ADMIN_LOGIN -> { - if (!adminAccessService.isApprovedAdmin(email)) { - redirect(request, response, "denied", "admin_login"); - return; - } - issueRefreshCookie(response, userAuth.getId(), "admin"); - redirect(request, response, "success", "admin_login"); - } - case USER_LOGIN -> { - issueRefreshCookie(response, userAuth.getId(), "user"); - redirect(request, response, "success", "user_login"); + if (flowType == OAuthFlowType.ADMIN_AUTH) { + if (adminAccessService.isApprovedAdmin(email)) { + issueRefreshCookie(response, + userAuth.getId(), "admin"); + redirectAdmin(request, response, + "success", "admin_login"); + return; } - default -> throw new StoneInscriptionException("Unsupported OAuth flow", HttpStatus.BAD_REQUEST); + adminAccessService + .createOrRefreshPendingRequest( + userAuth, name, provider); + redirectAdmin(request, response, + "pending", "admin_register"); + return; } + + issueRefreshCookie(response, + userAuth.getId(), "user"); + getRedirectStrategy().sendRedirect( + request, response, + frontendCallbackUrl + "?status=success"); } private UserAuth findOrCreateUser(String email, String name, String picture, String provider) { @@ -153,4 +155,15 @@ private void redirect( response, frontendCallbackUrl + "?status=" + status + "&flow=" + flow); } + + private void redirectAdmin( + HttpServletRequest request, + HttpServletResponse response, + String status, + String flow) throws IOException { + getRedirectStrategy().sendRedirect( + request, + response, + frontendAdminCallbackUrl + "?status=" + status + "&flow=" + flow); + } } diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java index f41acdf..be5d879 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowCookieService.java @@ -3,6 +3,7 @@ import java.time.Duration; import java.util.Arrays; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseCookie; import org.springframework.stereotype.Component; @@ -16,12 +17,16 @@ public class OAuthFlowCookieService { public static final String FLOW_COOKIE_NAME = "oauth_flow"; + @Value("${app.cookie.domain}") + private String cookieDomain; + public void storeFlow(HttpServletResponse response, OAuthFlowType flowType) { ResponseCookie cookie = ResponseCookie.from(FLOW_COOKIE_NAME, flowType.getValue()) .httpOnly(true) .secure(true) .sameSite("None") .path("/") + .domain(cookieDomain) .maxAge(Duration.ofMinutes(10)) .build(); diff --git a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java index e3fb526..bd4d070 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java +++ b/src/main/java/com/cadac/stone_inscription/auth/OAuthFlowType.java @@ -2,8 +2,7 @@ public enum OAuthFlowType { USER_LOGIN("user_login"), - ADMIN_REGISTER("admin_register"), - ADMIN_LOGIN("admin_login"); + ADMIN_AUTH("admin_auth"); private final String value; diff --git a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java index d83ad5b..199ef88 100644 --- a/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java +++ b/src/main/java/com/cadac/stone_inscription/auth/controller/OAuthController.java @@ -12,6 +12,9 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.admin.service.AdminAccessService; +import com.cadac.stone_inscription.api.dto.AccessTokenResponse; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.auth.OAuthFlowCookieService; import com.cadac.stone_inscription.auth.OAuthFlowType; import com.cadac.stone_inscription.auth.JwtUtil; @@ -26,12 +29,20 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; - -@RestController -@RequestMapping("/oauth2") - + +@RestController +@RequestMapping("/oauth2") +@Tag(name = "Authentication", description = "OAuth login redirects, refresh-token rotation, session activity, and logout.") + public class OAuthController { @Autowired @@ -48,21 +59,41 @@ public class OAuthController { @PostMapping("/logout") + @Operation( + summary = "Logout", + description = "Revokes the refresh token cookie when present and expires the browser cookie.", + responses = @ApiResponse(responseCode = "200", description = "Logged out", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Logged out successfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity> logoutAuth(HttpServletRequest request, HttpServletResponse response) throws JOSEException { return stoneAuthService.logoutAuth(request, response); } - @PostMapping("/authenticated/refresh-token") - public ResponseEntity> refreshToken(HttpServletResponse response, HttpServletRequest request) + @PostMapping("/authenticated/refresh-token") + @Operation( + summary = "Refresh access token", + description = "Rotates the HTTP-only refresh token cookie and returns a new JWT access token.", + responses = { + @ApiResponse(responseCode = "200", description = "Token refreshed", + content = @Content(schema = @Schema(implementation = AccessTokenResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Sucessfully updated\",\"http-status\":\"OK\",\"data\":{\"accessToken\":\"eyJhbGciOiJIUzI1NiJ9...\"}}"))), + @ApiResponse(responseCode = "401", description = "Refresh token is missing, revoked, or expired", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) + public ResponseEntity> refreshToken(HttpServletResponse response, HttpServletRequest request) throws UsernameNotFoundException, JOSEException { return stoneAuthService.refreshToken(request, response); } - @PostMapping("/authenticated/active") - public ResponseEntity> updateLastActive(HttpServletRequest request) { + @PostMapping("/authenticated/active") + @Operation( + summary = "Mark session active", + description = "Refreshes last-use time for the refresh-token session. Returns no content on success.", + responses = @ApiResponse(responseCode = "204", description = "Session marked active")) + public ResponseEntity> updateLastActive(HttpServletRequest request) { Cookie[] cookies = request.getCookies(); if (cookies == null) { throw new StoneInscriptionException("Unauthorized", HttpStatus.UNAUTHORIZED); @@ -81,7 +112,12 @@ public ResponseEntity> updateLastActive(HttpServletRequest request) { } @GetMapping("/login/{provider}") + @Operation( + summary = "Start user OAuth login", + description = "Stores the user-login flow marker and redirects to the configured OAuth provider.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to OAuth provider")) public void loginWithProvider( + @Parameter(description = "OAuth provider registration id.", example = "google") @PathVariable String provider, HttpServletResponse response) throws IOException { oAuthFlowCookieService.storeFlow(response, OAuthFlowType.USER_LOGIN); @@ -89,38 +125,33 @@ public void loginWithProvider( } @GetMapping("/login") + @Operation(summary = "Start default user OAuth login", description = "Redirects to the configured default OAuth provider.") public void login(HttpServletResponse response) throws IOException { loginWithProvider(defaultProvider, response); } - @GetMapping("/admin/register/{provider}") - public void adminRegister( + @GetMapping("/admin/authorization/{provider}") + public void adminAuth( @PathVariable String provider, HttpServletResponse response) throws IOException { - oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_REGISTER); + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); response.sendRedirect("/oauth2/authorization/" + provider); } - @GetMapping("/admin/register") - public void adminRegisterDefault(HttpServletResponse response) throws IOException { - adminRegister(defaultProvider, response); - } - - @GetMapping("/admin/login/{provider}") - public void adminLogin( - @PathVariable String provider, + @GetMapping("/admin/authorization") + public void adminAuthDefault( HttpServletResponse response) throws IOException { - oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_LOGIN); - response.sendRedirect("/oauth2/authorization/" + provider); - } - - @GetMapping("/admin/login") - public void adminLoginDefault(HttpServletResponse response) throws IOException { - adminLogin(defaultProvider, response); + oAuthFlowCookieService.storeFlow(response, OAuthFlowType.ADMIN_AUTH); + response.sendRedirect("/oauth2/authorization/" + defaultProvider); } @GetMapping("/admin/approve") + @Operation( + summary = "Approve admin request", + description = "Consumes an emailed approval token and redirects the browser to the configured approval-result page.", + responses = @ApiResponse(responseCode = "302", description = "Redirect to approval result page")) public void approveAdminRequest( + @Parameter(description = "Admin approval token.", required = true) @RequestParam("token") String token, HttpServletResponse response) throws IOException { try { diff --git a/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java new file mode 100644 index 0000000..de35f49 --- /dev/null +++ b/src/main/java/com/cadac/stone_inscription/configuration/OpenApiConfiguration.java @@ -0,0 +1,134 @@ +package com.cadac.stone_inscription.configuration; + +import org.springdoc.core.customizers.GlobalOperationCustomizer; +import org.springdoc.core.models.GroupedOpenApi; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.annotation.Secured; +import org.springframework.web.method.HandlerMethod; + +import io.swagger.v3.oas.annotations.enums.SecuritySchemeIn; +import io.swagger.v3.oas.annotations.enums.SecuritySchemeType; +import io.swagger.v3.oas.annotations.security.SecurityScheme; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.info.Contact; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; +import io.swagger.v3.oas.models.security.SecurityRequirement; + +@Configuration +@SecurityScheme( + name = OpenApiConfiguration.BEARER_AUTH, + type = SecuritySchemeType.HTTP, + scheme = "bearer", + bearerFormat = "JWT", + in = SecuritySchemeIn.HEADER) +public class OpenApiConfiguration { + + public static final String BEARER_AUTH = "bearerAuth"; + + private static final String ERROR_SCHEMA_REF = "#/components/schemas/ApiErrorResponse"; + + @Bean + public OpenAPI stoneInscriptionOpenAPI() { + return new OpenAPI() + .info(new Info() + .title("Stone Inscription API") + .version("v1") + .description(""" + REST API for OAuth authentication, user profiles, inscription posts, images, public dashboard metrics, and moderation reports. + + Most JSON endpoints return a standard envelope with `message`, `http-status`, and `data`. Protected endpoints use a JWT bearer token in the `Authorization` header. + """) + .contact(new Contact().name("C-DAC Stone Inscription")) + .license(new License().name("Internal"))) + .components(new Components()); + } + + @Bean + public GroupedOpenApi authenticationApi() { + return GroupedOpenApi.builder() + .group("01-authentication") + .displayName("Authentication") + .pathsToMatch("/oauth2/**", "/api/v1/**") + .pathsToExclude("/api/v1/") + .build(); + } + + @Bean + public GroupedOpenApi userApi() { + return GroupedOpenApi.builder() + .group("02-users") + .displayName("Users") + .pathsToMatch("/user/**") + .build(); + } + + @Bean + public GroupedOpenApi postApi() { + return GroupedOpenApi.builder() + .group("03-posts") + .displayName("Posts") + .pathsToMatch("/post/**") + .pathsToExclude("/post/test/**") + .build(); + } + + @Bean + public GroupedOpenApi reportApi() { + return GroupedOpenApi.builder() + .group("04-reports") + .displayName("Reports") + .pathsToMatch("/report", "/reports", "/moderate/**") + .pathsToExclude("/test/**") + .build(); + } + + @Bean + public GlobalOperationCustomizer commonApiResponsesCustomizer() { + return (operation, handlerMethod) -> { + if (operation.getResponses() == null) { + operation.setResponses(new ApiResponses()); + } + + if (isSecured(handlerMethod)) { + operation.addSecurityItem(new SecurityRequirement().addList(BEARER_AUTH)); + addErrorResponse(operation, "401", "JWT is missing, expired, or invalid."); + addErrorResponse(operation, "403", "Authenticated user does not have the required role."); + } + + addErrorResponse(operation, "400", "Request is syntactically valid but violates business rules."); + addErrorResponse(operation, "422", "Bean validation failed for request body, form, or query parameters."); + addErrorResponse(operation, "500", "Unexpected server error."); + + return operation; + }; + } + + private boolean isSecured(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(Secured.class) + || handlerMethod.getBeanType().isAnnotationPresent(Secured.class); + } + + private void addErrorResponse(Operation operation, String code, String description) { + if (operation.getResponses().containsKey(code)) { + return; + } + + operation.getResponses().addApiResponse(code, new ApiResponse() + .description(description) + .content(jsonContent(ERROR_SCHEMA_REF))); + } + + private Content jsonContent(String schemaRef) { + return new Content().addMediaType(org.springframework.http.MediaType.APPLICATION_JSON_VALUE, + new MediaType().schema(new Schema<>().$ref(schemaRef))); + } +} diff --git a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java index 4ec8632..4ae2759 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationRequestDto.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,14 +12,18 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ContentModerationRequest", description = "Text fields sent to the content moderation workflow.") public class ContentModerationRequestDto { @JsonProperty("title") + @Schema(description = "Content title.", example = "Ashokan pillar fragment") private String title; @JsonProperty("topic") + @Schema(description = "Content topic.", example = "Temple inscription") private String topic; @JsonProperty("description") + @Schema(description = "Content body to moderate.", example = "Fragmentary stone inscription found near the temple entrance.") private String description; } diff --git a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java index ef985d0..c1bf026 100644 --- a/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java +++ b/src/main/java/com/cadac/stone_inscription/moderation/dto/ContentModerationResponseDto.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonAlias; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -14,40 +15,51 @@ @NoArgsConstructor @AllArgsConstructor @JsonIgnoreProperties(ignoreUnknown = true) +@Schema(name = "ContentModerationResponse", description = "Normalized moderation webhook response.") public class ContentModerationResponseDto { @JsonProperty("timestamp") + @Schema(description = "Webhook timestamp.", example = "2026-05-19T10:35:00Z") private String timestamp; @JsonProperty("decision") @JsonAlias({ "verdict", "action" }) + @Schema(description = "Moderation decision.", example = "ALLOW") private String decision; @JsonProperty("label") @JsonAlias({ "category", "classification" }) + @Schema(description = "Moderation category or label.", example = "safe") private String label; @JsonProperty("confidence") @JsonAlias({ "score", "confidenceScore", "confidence_score", "probability" }) + @Schema(description = "Confidence score from 0 to 1 when supplied.", example = "0.91") private Double confidence; @JsonProperty("reason") @JsonAlias({ "message", "explanation" }) + @Schema(description = "Human-readable moderation reason.", example = "No unsafe content detected") private String reason; @JsonProperty("status") @JsonAlias({ "state" }) + @Schema(description = "Webhook processing status.", example = "APPROVED") private String status; @JsonProperty("description") + @Schema(description = "Moderated content description returned by the workflow.") private String description; @JsonProperty("id") + @Schema(description = "Webhook-side moderation id.", example = "5006") private Long id; @JsonProperty("createdAt") + @Schema(description = "Webhook record creation timestamp.", example = "2026-05-19T10:35:00Z") private String createdAt; @JsonProperty("updatedAt") + @Schema(description = "Webhook record update timestamp.", example = "2026-05-19T10:35:01Z") private String updatedAt; } diff --git a/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java b/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java index 320f251..111057b 100644 --- a/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java +++ b/src/main/java/com/cadac/stone_inscription/post/controller/PostController.java @@ -21,14 +21,26 @@ import org.springframework.web.multipart.MultipartFile; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.DashboardCountsResponse; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.post.dto.InscriptionPostDto; import com.cadac.stone_inscription.post.service.PostService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; @RestController @RequestMapping("/post") +@Tag(name = "Posts", description = "Inscription post, image, description, rating, and dashboard APIs.") public class PostController { private static final int MAX_IMAGES_PER_POST = 16; @@ -45,9 +57,17 @@ public class PostController { @PostMapping("/addPostWithFile") @Secured("user") + @Operation( + summary = "Create post with images", + description = "Creates an inscription post from multipart metadata and one or more images. The server validates extension, image count, size, metadata, geolocation, perceptual hash data, and content moderation.", + responses = @ApiResponse(responseCode = "200", description = "Post images uploaded", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Images Uploaded Sucessfully\",\"http-status\":\"OK\",\"data\":true}")))) public ResponseEntity> addPostWithFile( + @Parameter(description = "Post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, HttpServletRequest request, + @Parameter(description = "Image files. Maximum 16 files, 75 MB each.", required = true) @RequestPart("files") MultipartFile... files) throws IOException { files = getNonEmptyFiles(files); @@ -68,6 +88,11 @@ public ResponseEntity> addPostWithFile( @PostMapping("/getAllPost") @Secured("user") + @Operation( + summary = "List all visible posts", + description = "Returns all posts with image identifiers expanded to public image URLs.", + responses = @ApiResponse(responseCode = "200", description = "Posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> getAllPost() { return postService.getAllPost(); @@ -75,6 +100,15 @@ public ResponseEntity> getAllPost() { } @GetMapping("/public/images/{id}") + @Operation( + summary = "Download post image", + description = "Public endpoint that streams a stored inscription image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getImage(@PathVariable String id) { return postService.getImages(id); @@ -83,6 +117,11 @@ public ResponseEntity getImage(@PathVariable String id) { @PostMapping("/getAllUserPost") @Secured("user") + @Operation( + summary = "List my posts", + description = "Returns posts created by the authenticated user with image URLs hydrated.", + responses = @ApiResponse(responseCode = "200", description = "User posts fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> getAllUserPost(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -97,8 +136,14 @@ public ResponseEntity> getAllUserPost(HttpServletRequest request) { @PostMapping("/addPoastDiscription") @Secured("user") - public ResponseEntity> addPoastDiscription(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("discription") String discription) { + @Operation( + summary = "Add post description", + description = "Adds a user-authored description/comment to a post after content moderation. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> addPoastDiscription(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Description text.", example = "This line appears to mention a land grant.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -112,7 +157,13 @@ public ResponseEntity> addPoastDiscription(HttpServletRequest request, @Reques @PostMapping("/getPostDiscription") @Secured("user") - public ResponseEntity> getPostDiscription(@RequestParam("postId") String postId) { + @Operation( + summary = "List post descriptions", + description = "Returns all user descriptions/comments for a post. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> getPostDiscription( + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { return postService.getPostDiscription( postId); @@ -121,8 +172,15 @@ public ResponseEntity> getPostDiscription(@RequestParam("postId") String postI @PostMapping("/updatePostDiscription") @Secured("user") + @Operation( + summary = "Update post description", + description = "Updates an authenticated user's own description/comment after moderation.", + responses = @ApiResponse(responseCode = "200", description = "Description updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> updatePostDiscription(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("discriptionId") String discriptionId, + @Parameter(description = "Updated description text.", example = "Updated historical reading.") @RequestParam("discription") String discription) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -136,8 +194,14 @@ public ResponseEntity> updatePostDiscription(HttpServletRequest request, @PostMapping("/addRating") @Secured("user") - public ResponseEntity> addRating(HttpServletRequest request, @RequestParam("postId") String postId, - @RequestParam("rating") Double rating) { + @Operation( + summary = "Rate post", + description = "Adds or updates the authenticated user's numeric rating for a post.", + responses = @ApiResponse(responseCode = "200", description = "Rating added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> addRating(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId, + @Parameter(description = "Rating value.", example = "4.5") @RequestParam("rating") Double rating) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -151,7 +215,13 @@ public ResponseEntity> addRating(HttpServletRequest request, @RequestParam("po @PostMapping("/addVote") @Secured("user") - public ResponseEntity> addVote(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Toggle description vote", + description = "Adds an upvote when the authenticated user has not voted, or removes the existing vote.", + responses = @ApiResponse(responseCode = "200", description = "Vote updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> addVote(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -165,6 +235,11 @@ public ResponseEntity> addVote(HttpServletRequest request, @RequestParam("desc @PostMapping("/userProfile") @Secured("user") + @Operation( + summary = "Get current user entity profile", + description = "Legacy post-module profile endpoint that returns the authenticated user object in the standard envelope.", + responses = @ApiResponse(responseCode = "200", description = "User profile fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> userProfile(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -177,7 +252,13 @@ public ResponseEntity> userProfile(HttpServletRequest request) { @PostMapping("/postDelete") @Secured("user") - public ResponseEntity> postDelete(HttpServletRequest request, @RequestParam("postId") String postId) { + @Operation( + summary = "Delete my post", + description = "Deletes a post owned by the authenticated user and archives related content according to the content delete service.", + responses = @ApiResponse(responseCode = "200", description = "Post deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> postDelete(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam("postId") String postId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -190,7 +271,13 @@ public ResponseEntity> postDelete(HttpServletRequest request, @RequestParam("p @PostMapping("/discriptionDelete") @Secured("user") - public ResponseEntity> descriptionDelete(HttpServletRequest request, @RequestParam("descriptionId") String descriptionId) { + @Operation( + summary = "Delete my description", + description = "Deletes a description/comment owned by the authenticated user. Endpoint spelling preserves the existing API contract.", + responses = @ApiResponse(responseCode = "200", description = "Description deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) + public ResponseEntity> descriptionDelete(HttpServletRequest request, + @Parameter(description = "Description id.", example = "665f1df013ad4e18f6a11247") @RequestParam("descriptionId") String descriptionId) { String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { @@ -204,10 +291,19 @@ public ResponseEntity> descriptionDelete(HttpServletRequest request, @RequestP // Helps logged user to create a post and upload images @PostMapping("/updatePost") @Secured("user") + @Operation( + summary = "Update post metadata and images", + description = "Updates post metadata, removes selected images, adds new images, and enforces that at least one image remains and no more than 16 images are attached.", + responses = @ApiResponse(responseCode = "200", description = "Post updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> updatePost(HttpServletRequest request, + @Parameter(description = "Updated post metadata JSON part.", required = false) @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to remove from the post.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds", required = false) List deletedImageIds, + @Parameter(description = "New image files to add.", required = false) @RequestPart(value = "files", required = false) MultipartFile... files) { files = getNonEmptyFiles(files); @@ -225,8 +321,15 @@ public ResponseEntity> updatePost(HttpServletRequest request, @PostMapping("/addImagesToPost") @Secured("user") + @Operation( + summary = "Add images to post", + description = "Adds one or more images to an owned post while enforcing extension, size, and max image-count rules.", + responses = @ApiResponse(responseCode = "200", description = "Images added", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> addImagesToPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image files. Maximum 16 images per post total.", required = true) @RequestPart("files") MultipartFile... files) { files = getNonEmptyFiles(files); @@ -247,8 +350,15 @@ public ResponseEntity> addImagesToPost(HttpServletRequest request, @PostMapping("/deleteImagesFromPost") @Secured("user") + @Operation( + summary = "Delete post images", + description = "Deletes selected images from an owned post while ensuring the post still has at least one image.", + responses = @ApiResponse(responseCode = "200", description = "Images deleted", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> deleteImagesFromPost(HttpServletRequest request, + @Parameter(description = "Post id.", example = "665f1df013ad4e18f6a11244") @RequestParam String postId, + @Parameter(description = "Image ids to delete.", example = "[\"665f1df013ad4e18f6a11249\"]") @RequestParam(value = "deletedImageIds") List deletedImageIds) { String token = request.getHeader("Authorization"); @@ -279,6 +389,7 @@ public ResponseEntity> deleteImagesFromPost(HttpServletRequest request, // } @PostMapping("/test/addPostWithFile/{email}") + @Hidden public ResponseEntity> addPostWithFileForTest( @PathVariable String email, @RequestPart(value = "post", required = false) InscriptionPostDto InscriptionPostDto, @@ -375,6 +486,11 @@ private void validateFiles(MultipartFile[] files, int maxFilesAllowed) { @PostMapping("/getCommentByUser") // @Secured("user") + @Operation( + summary = "List my descriptions", + description = "Returns descriptions/comments authored by the authenticated user with post preview image URLs.", + responses = @ApiResponse(responseCode = "200", description = "User descriptions fetched", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> getCommentByUser(HttpServletRequest request) { String token = request.getHeader("Authorization"); @@ -388,6 +504,12 @@ public ResponseEntity> getCommentByUser(HttpServletRequest request) { @GetMapping("/public/getDashboardCounts") // @Secured("user") + @Operation( + summary = "Get public dashboard counts", + description = "Returns public aggregate counts used by the dashboard.", + responses = @ApiResponse(responseCode = "200", description = "Dashboard counts fetched", + content = @Content(schema = @Schema(implementation = DashboardCountsResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Dashboard Counts\",\"http-status\":\"OK\",\"data\":{\"totalUsers\":128,\"totalPosts\":42,\"totalImages\":280,\"totalGeoTaggedPosts\":31,\"totalTranslations\":17}}")))) public ResponseEntity> getDashboardCounts() { return postService.getDashboardCounts(); diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java index 3bd2532..727e0db 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/InscriptionPostDto.java @@ -1,56 +1,70 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class InscriptionPostDto { - - @JsonProperty("description") - private DescriptionDto description; - - @JsonProperty("topic") - private String topic; - - @JsonProperty("script") - private List script; - - @JsonProperty("type") - private String type; - - @JsonProperty("visiblity") - private Boolean visiblity; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "InscriptionPost", description = "Post metadata submitted with one or more inscription images in multipart requests.") +public class InscriptionPostDto { + + @JsonProperty("description") + @Schema(description = "Localized inscription description and language metadata.") + private DescriptionDto description; + + @JsonProperty("topic") + @Schema(description = "High-level post topic.", example = "Temple inscription") + private String topic; + + @JsonProperty("script") + @ArraySchema(schema = @Schema(description = "Script family used in the inscription.", example = "Brahmi")) + private List script; + + @JsonProperty("type") + @Schema(description = "Content type or category.", example = "STONE_INSCRIPTION") + private String type; + + @JsonProperty("visiblity") + @Schema(description = "Whether the post should be visible publicly. Field name preserves the existing API spelling.", example = "true") + private Boolean visiblity; @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class DescriptionDto { - - @JsonProperty("title") - private String title; - - @JsonProperty("subject") - private String subject; - - @JsonProperty("description") - private String description; - - @JsonProperty("scriptLanguage") - private List scriptLanguage; - - @JsonProperty("language") - private List language; - - } + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "InscriptionPostDescription", description = "Human-readable inscription description payload.") + public static class DescriptionDto { + + @JsonProperty("title") + @Schema(description = "Display title for the inscription.", example = "Ashokan pillar fragment") + private String title; + + @JsonProperty("subject") + @Schema(description = "Primary subject of the inscription.", example = "Donation record") + private String subject; + + @JsonProperty("description") + @Schema(description = "Detailed inscription description.", example = "Fragmentary stone inscription found near the temple entrance.") + private String description; + + @JsonProperty("scriptLanguage") + @ArraySchema(schema = @Schema(description = "Script language.", example = "Prakrit")) + private List scriptLanguage; + + @JsonProperty("language") + @ArraySchema(schema = @Schema(description = "Readable language.", example = "Hindi")) + private List language; + + } } diff --git a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java index 40068df..a912f24 100644 --- a/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java +++ b/src/main/java/com/cadac/stone_inscription/post/dto/PublicPostUserDescriptionDto.java @@ -1,25 +1,30 @@ package com.cadac.stone_inscription.post.dto; -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.*; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; import java.util.Date; import java.util.List; -@Data -@Builder -@NoArgsConstructor -@AllArgsConstructor -public class PublicPostUserDescriptionDto { - - @JsonProperty("id") - private String id; - - @JsonProperty("postId") - private String postId; - - @JsonProperty("userId") - private String userId; +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(name = "PublicPostUserDescription", description = "Description/comment authored by a user with voting and image preview details.") +public class PublicPostUserDescriptionDto { + + @JsonProperty("id") + @Schema(description = "Description identifier.", example = "665f1df013ad4e18f6a11247") + private String id; + + @JsonProperty("postId") + @Schema(description = "Associated post identifier.", example = "665f1df013ad4e18f6a11244") + private String postId; + + @JsonProperty("userId") + @Schema(description = "Author user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; @JsonProperty("username") private String username; @@ -30,11 +35,13 @@ public class PublicPostUserDescriptionDto { @JsonProperty("postImageUrl") private String postImageUrl; - @JsonProperty("description") - private String description; - - @JsonProperty("upvote") - private Integer upvote; + @JsonProperty("description") + @Schema(description = "Description text.", example = "The inscription appears to reference a land grant.") + private String description; + + @JsonProperty("upvote") + @Schema(description = "Current upvote count.", example = "7") + private Integer upvote; @JsonProperty("userVote") private List userVote; @@ -45,12 +52,14 @@ public class PublicPostUserDescriptionDto { @JsonProperty("updatedAt") private Date updatedAt; - @Data - @Builder - @NoArgsConstructor - @AllArgsConstructor - public static class UserVoteDto { - @JsonProperty("userId") - private String userId; - } -} + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @Schema(name = "UserVote", description = "User vote marker for a description.") + public static class UserVoteDto { + @JsonProperty("userId") + @Schema(description = "Voting user identifier.", example = "665f1df013ad4e18f6a11240") + private String userId; + } +} diff --git a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java index 34d6723..79d67da 100644 --- a/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java +++ b/src/main/java/com/cadac/stone_inscription/report/controller/ReportController.java @@ -11,19 +11,33 @@ import org.springframework.web.bind.annotation.RestController; import com.cadac.stone_inscription.auth.JwtUtil; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; +import com.cadac.stone_inscription.api.dto.ReportQueuedResponse; import com.cadac.stone_inscription.exception.StoneInscriptionException; import com.cadac.stone_inscription.report.dto.CreateReportRequest; import com.cadac.stone_inscription.report.dto.ModerateReportRequest; +import com.cadac.stone_inscription.report.entity.ModerationReport; import com.cadac.stone_inscription.report.enums.ReportStatus; import com.cadac.stone_inscription.report.service.ReportService; import com.cadac.stone_inscription.report.service.ReportSubmissionService; +import io.swagger.v3.oas.annotations.Hidden; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @RestController @RequiredArgsConstructor +@Tag(name = "Reports", description = "User reporting and moderator review workflow.") public class ReportController { private final ReportService reportService; @@ -32,6 +46,20 @@ public class ReportController { @PostMapping("/report") @Secured({ "user", "admin" }) + @Operation( + summary = "Submit report", + description = "Accepts a report from the authenticated user, validates target/reporting rules, and queues the report for AI moderation.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = CreateReportRequest.class), + examples = @ExampleObject(value = "{\"targetType\":\"POST\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"reason\":\"MISINFORMATION\",\"details\":\"The description attributes the inscription to the wrong dynasty.\"}"))), + responses = { + @ApiResponse(responseCode = "202", description = "Report queued", + content = @Content(schema = @Schema(implementation = ReportQueuedResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Report submitted for AI moderation\",\"http-status\":\"ACCEPTED\",\"data\":{\"eventId\":\"7f8b7d33-16f2-4e84-9f54-8085a9e84791\",\"targetId\":\"665f1df013ad4e18f6a11244\",\"targetType\":\"POST\",\"status\":\"QUEUED\"}}"))), + @ApiResponse(responseCode = "400", description = "Invalid target, duplicate report, or self-report", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity> createReport( HttpServletRequest request, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -40,6 +68,7 @@ public ResponseEntity> createReport( } @PostMapping("/test/report/{email}") + @Hidden public ResponseEntity> createReportForTest( @PathVariable String email, @Valid @RequestBody CreateReportRequest createReportRequest) { @@ -49,14 +78,21 @@ public ResponseEntity> createReportForTest( @GetMapping("/reports") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "List moderation reports", + description = "Returns moderation reports ordered by creation time. Moderators may optionally filter by status.", + responses = @ApiResponse(responseCode = "200", description = "Reports fetched", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ModerationReport.class))))) public ResponseEntity> getReports( HttpServletRequest request, + @Parameter(description = "Optional report status filter.", example = "ESCALATED") @RequestParam(required = false) ReportStatus status) { return reportService.getReports(extractEmailFromToken(request), status); } @GetMapping("/test/reports/{email}") + @Hidden public ResponseEntity> getReportsForTest( @PathVariable String email, @RequestParam(required = false) ReportStatus status) { @@ -66,6 +102,19 @@ public ResponseEntity> getReportsForTest( @PostMapping("/moderate/{id}") @Secured({ "admin", "moderator", "human_moderator", "ai_moderator" }) + @Operation( + summary = "Moderate report", + description = "Runs the moderation chain for a report. Human moderator roles are required when an escalated report needs final resolution.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = false, + content = @Content(schema = @Schema(implementation = ModerateReportRequest.class), + examples = @ExampleObject(value = "{\"action\":\"REMOVE_CONTENT\",\"note\":\"Removed after human review.\"}"))), + responses = { + @ApiResponse(responseCode = "200", description = "Report moderation completed", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class))), + @ApiResponse(responseCode = "404", description = "Report not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity> moderateReport( HttpServletRequest request, @PathVariable String id, @@ -75,6 +124,7 @@ public ResponseEntity> moderateReport( } @PostMapping("/test/moderate/{id}/{email}") + @Hidden public ResponseEntity> moderateReportForTest( @PathVariable String id, @PathVariable String email, diff --git a/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java index 2390801..85ffb0b 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/CreateReportRequest.java @@ -3,23 +3,29 @@ import com.cadac.stone_inscription.report.enums.ReportReason; import com.cadac.stone_inscription.report.enums.ReportTargetType; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "CreateReportRequest", description = "Request used by authenticated users to report a post, comment, or user.") public class CreateReportRequest { + @Schema(description = "Type of resource being reported.", example = "POST", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportTargetType targetType; + @Schema(description = "MongoDB ObjectId or canonical identifier of the reported resource.", example = "665f1df013ad4e18f6a11244", requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank private String targetId; + @Schema(description = "Reason selected by the reporter.", example = "MISINFORMATION", requiredMode = Schema.RequiredMode.REQUIRED) @NotNull private ReportReason reason; + @Schema(description = "Reporter-provided context for moderators. Maximum 1000 characters.", example = "The inscription description contains misleading historical attribution.", maxLength = 1000, requiredMode = Schema.RequiredMode.REQUIRED) @NotBlank @Size(max = 1000) private String details; diff --git a/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java index 35e26c5..8ce742d 100644 --- a/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java +++ b/src/main/java/com/cadac/stone_inscription/report/dto/ModerateReportRequest.java @@ -2,14 +2,18 @@ import com.cadac.stone_inscription.report.enums.ModerationAction; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Size; import lombok.Data; @Data +@Schema(name = "ModerateReportRequest", description = "Optional instruction supplied by a moderator when processing a report.") public class ModerateReportRequest { + @Schema(description = "Moderation action to apply. If omitted, the moderation chain decides the next transition.", example = "REMOVE_CONTENT") private ModerationAction action; + @Schema(description = "Moderator note saved in the report audit trail. Maximum 1000 characters.", example = "Removed duplicate inscription image after review.", maxLength = 1000) @Size(max = 1000) private String note; } diff --git a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java index b4fa165..11d31fb 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ModerationReport.java @@ -28,6 +28,8 @@ import com.fasterxml.jackson.databind.annotation.JsonSerialize; import com.fasterxml.jackson.databind.ser.std.ToStringSerializer; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -42,42 +44,51 @@ @CompoundIndex(name = "report_target_idx", def = "{'targetId': 1, 'targetType': 1}"), @CompoundIndex(name = "reporter_target_idx", def = "{'reporterId': 1, 'targetId': 1, 'targetType': 1}") }) +@Schema(name = "ModerationReport", description = "Moderation report state, target metadata, AI score, and audit history.") public class ModerationReport { @Id @JsonProperty("_id") @JsonSerialize(using = ToStringSerializer.class) + @Schema(description = "Report identifier.", example = "665f1df013ad4e18f6a11250") private ObjectId id; @Field("reporterId") @JsonProperty("reporterId") @Indexed + @Schema(description = "Reporter user id.", example = "665f1df013ad4e18f6a11240") private String reporterId; @Field("targetId") @JsonProperty("targetId") @Indexed + @Schema(description = "Reported target id.", example = "665f1df013ad4e18f6a11244") private String targetId; @Field("targetType") @JsonProperty("targetType") + @Schema(description = "Reported target type.", example = "POST") private ReportTargetType targetType; @Field("targetAuthorId") @JsonProperty("targetAuthorId") + @Schema(description = "Author id of the reported target.", example = "665f1df013ad4e18f6a11241") private String targetAuthorId; @Field("reason") @JsonProperty("reason") + @Schema(description = "Reporter-selected reason.", example = "MISINFORMATION") private ReportReason reason; @Field("details") @JsonProperty("details") + @Schema(description = "Reporter-provided details.", example = "The inscription description contains misleading attribution.") private String details; @Field("status") @JsonProperty("status") @Indexed + @Schema(description = "Current moderation workflow status.", example = "ESCALATED") private ReportStatus status; @Field("activeReportKey") @@ -88,15 +99,18 @@ public class ModerationReport { @Field("actionTaken") @JsonProperty("actionTaken") @Builder.Default + @Schema(description = "Action applied during moderation.", example = "REMOVE_CONTENT") private ModerationAction actionTaken = ModerationAction.NONE; @Field("resolvedBy") @JsonProperty("resolvedBy") + @Schema(description = "Actor that resolved the report.", example = "moderator@example.com") private String resolvedBy; @Field("aiConfidenceScore") @JsonProperty("aiConfidenceScore") @Builder.Default + @Schema(description = "AI confidence score from 0 to 1.", example = "0.87") private Double aiConfidenceScore = 0.0; @CreatedDate @@ -121,6 +135,7 @@ public class ModerationReport { @Field("auditEntries") @JsonProperty("auditEntries") @Builder.Default + @ArraySchema(schema = @Schema(implementation = ReportAuditEntry.class)) private List auditEntries = new ArrayList<>(); public static String buildActiveReportKey(String reporterId, String targetId, ReportTargetType targetType) { diff --git a/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java index 679b197..6829bf8 100644 --- a/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java +++ b/src/main/java/com/cadac/stone_inscription/report/entity/ReportAuditEntry.java @@ -6,6 +6,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -15,17 +16,21 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "ReportAuditEntry", description = "Single audit trail entry recorded during report moderation.") public class ReportAuditEntry { @Field("actor") @JsonProperty("actor") + @Schema(description = "Actor email or system actor name.", example = "moderator@example.com") private String actor; @Field("message") @JsonProperty("message") + @Schema(description = "Audit message.", example = "Status -> RESOLVED | Action -> REMOVE_CONTENT | By -> moderator@example.com") private String message; @Field("createdAt") @JsonProperty("createdAt") + @Schema(description = "Audit creation timestamp.", example = "2026-05-19T10:35:00.000+00:00") private Date createdAt; } diff --git a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java index 92d9abb..a7be450 100644 --- a/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java +++ b/src/main/java/com/cadac/stone_inscription/user/controller/UserController.java @@ -16,14 +16,25 @@ import com.cadac.stone_inscription.auth.JwtUtil; import com.cadac.stone_inscription.exception.StoneInscriptionException; +import com.cadac.stone_inscription.api.dto.ApiErrorResponse; +import com.cadac.stone_inscription.api.dto.ApiSuccessResponse; import com.cadac.stone_inscription.user.dto.UpdateProfileRequest; +import com.cadac.stone_inscription.user.dto.UserProfileResponse; import com.cadac.stone_inscription.user.service.UserService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @RestController @RequestMapping("/user") +@Tag(name = "Users", description = "Authenticated user profile and image management APIs.") public class UserController { @Autowired @@ -38,6 +49,16 @@ public class UserController { */ @GetMapping("/profile") @Secured("user") + @Operation( + summary = "Get my profile", + description = "Returns the profile attached to the JWT subject.", + responses = { + @ApiResponse(responseCode = "200", description = "Profile fetched successfully", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class), + examples = @ExampleObject(value = "{\"message\":\"Profile fetched successfully\",\"http-status\":\"OK\",\"data\":{\"id\":\"665f1df013ad4e18f6a11244\",\"name\":\"Asha Rao\",\"username\":\"asha_rao\",\"email\":\"asha@example.com\",\"profileImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245\",\"coverImage\":\"https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246\",\"bio\":\"Epigraphy researcher\",\"imagesUploaded\":12,\"upvotesReceived\":34,\"followers\":8,\"points\":240}}"))), + @ApiResponse(responseCode = "401", description = "Token is missing or user cannot be resolved", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity> getProfile(HttpServletRequest request) { String email = extractEmailFromToken(request); return userService.getProfile(email); @@ -49,6 +70,15 @@ public ResponseEntity> getProfile(HttpServletRequest request) { */ @PostMapping("/updateProfile") @Secured("user") + @Operation( + summary = "Update my profile", + description = "Updates the authenticated user's editable profile fields. Bean validation constraints are visible in the request schema.", + requestBody = @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + content = @Content(schema = @Schema(implementation = UpdateProfileRequest.class), + examples = @ExampleObject(value = "{\"username\":\"inscription_scholar\",\"bio\":\"Epigraphy researcher\"}"))), + responses = @ApiResponse(responseCode = "200", description = "Profile updated successfully", + content = @Content(schema = @Schema(implementation = UserProfileResponse.class)))) public ResponseEntity> updateProfile( HttpServletRequest request, @Valid @RequestBody UpdateProfileRequest updateProfileRequest) { @@ -64,8 +94,14 @@ public ResponseEntity> updateProfile( */ @PostMapping("/uploadProfileImage") @Secured("user") + @Operation( + summary = "Upload profile image", + description = "Replaces the authenticated user's profile image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Profile image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> uploadProfileImage( HttpServletRequest request, + @Parameter(description = "Profile image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -78,8 +114,14 @@ public ResponseEntity> uploadProfileImage( */ @PostMapping("/uploadCoverImage") @Secured("user") + @Operation( + summary = "Upload cover image", + description = "Replaces the authenticated user's cover image. Accepts one multipart image file using the configured extension allow-list.", + responses = @ApiResponse(responseCode = "200", description = "Cover image updated", + content = @Content(schema = @Schema(implementation = ApiSuccessResponse.class)))) public ResponseEntity> uploadCoverImage( HttpServletRequest request, + @Parameter(description = "Cover image file. Allowed extensions come from `file.extn`.", required = true) @RequestPart("file") MultipartFile file) { String email = extractEmailFromToken(request); @@ -91,6 +133,15 @@ public ResponseEntity> uploadCoverImage( * GET /api/v1/user/public/images/{id} */ @GetMapping("/public/images/{id}") + @Operation( + summary = "Download user image", + description = "Public endpoint that streams a profile or cover image by id.", + responses = { + @ApiResponse(responseCode = "200", description = "Image stream", + content = @Content(mediaType = "image/*", schema = @Schema(type = "string", format = "binary"))), + @ApiResponse(responseCode = "404", description = "Image not found", + content = @Content(schema = @Schema(implementation = ApiErrorResponse.class))) + }) public ResponseEntity getUserImage(@PathVariable String id) { return userService.getUserImage(id); } diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java index a26e50f..d7fe911 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UpdateProfileRequest.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.*; @@ -10,13 +11,16 @@ @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UpdateProfileRequest", description = "Editable profile fields for the authenticated user.") public class UpdateProfileRequest { @JsonProperty("username") + @Schema(description = "Unique username. Submit only when changing it.", example = "inscription_scholar", minLength = 3, maxLength = 25) @Size(min = 3, max = 25, message = "Username must be between 3 and 25 characters") private String username; @JsonProperty("bio") + @Schema(description = "Short profile bio. Letters, numbers, and spaces only.", example = "Epigraphy researcher", minLength = 3, maxLength = 150, pattern = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$") @Size(min = 3, max = 150, message = "Bio must be between 3 and 150 characters") @Pattern(regexp = "^(?=.*[A-Za-z0-9])[A-Za-z0-9 ]+$", message = "Bio can only contain letters, numbers, and spaces") private String bio; diff --git a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java index 74e8c0d..d95fd10 100644 --- a/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java +++ b/src/main/java/com/cadac/stone_inscription/user/dto/UserProfileResponse.java @@ -2,44 +2,57 @@ import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @Data @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(name = "UserProfile", description = "Profile details returned for the authenticated user.") public class UserProfileResponse { @JsonProperty("id") + @Schema(description = "User identifier.", example = "665f1df013ad4e18f6a11244") private String id; @JsonProperty("name") + @Schema(description = "OAuth provider display name.", example = "Asha Rao") private String name; @JsonProperty("username") + @Schema(description = "Application username.", example = "asha_rao") private String username; @JsonProperty("email") + @Schema(description = "User email address.", example = "asha@example.com", format = "email") private String email; @JsonProperty("profileImage") + @Schema(description = "Public profile image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11245") private String profileImage; @JsonProperty("coverImage") + @Schema(description = "Public cover image URL.", example = "https://inscriptions.cdacb.in/api/user/public/images/665f1df013ad4e18f6a11246") private String coverImage; @JsonProperty("bio") + @Schema(description = "Short user bio.", example = "Epigraphy researcher") private String bio; @JsonProperty("imagesUploaded") + @Schema(description = "Number of uploaded inscription images.", example = "12") private Integer imagesUploaded; @JsonProperty("upvotesReceived") + @Schema(description = "Total upvotes received on user comments/descriptions.", example = "34") private Integer upvotesReceived; @JsonProperty("followers") + @Schema(description = "Follower count.", example = "8") private Integer followers; @JsonProperty("points") + @Schema(description = "Reputation points.", example = "240") private Integer points; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7fed212..afb70ea 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -13,9 +13,11 @@ spring.data.mongodb.uri=${MONGO_URI} # CORS configuration app.cors.url=${APP_CORS_URL:https://inscriptions.cdacb.in} +app.cookie.domain=${APP_COOKIE_DOMAIN} app.backend.url=${APP_BACKEND_URL:https://inscriptions.cdacb.in/api} app.frontend.oauth.callback-url=${APP_FRONTEND_OAUTH_CALLBACK_URL:https://inscriptions.cdacb.in/oauth/callback} +app.frontend.oauth.admin-callback-url=${APP_FRONTEND_OAUTH_ADMIN_CALLBACK_URL} app.frontend.admin.approval-result-url=${APP_FRONTEND_ADMIN_APPROVAL_RESULT_URL:https://inscriptions.cdacb.in/admin/approval-result} admin.approval.token-validity-ms=${ADMIN_APPROVAL_TOKEN_VALIDITY_MS:86400000} @@ -63,6 +65,16 @@ logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG spring.servlet.multipart.max-file-size=75MB spring.servlet.multipart.max-request-size=1200MB +# OpenAPI / Swagger UI +springdoc.api-docs.path=/v3/api-docs +springdoc.swagger-ui.path=/swagger-ui.html +springdoc.swagger-ui.display-request-duration=true +springdoc.swagger-ui.doc-expansion=none +springdoc.swagger-ui.filter=true +springdoc.swagger-ui.operations-sorter=alpha +springdoc.swagger-ui.tags-sorter=alpha +springdoc.writer-with-order-by-keys=true + management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.prometheus.enabled=true management.metrics.export.prometheus.enabled=true diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 77d2ec8..5f1dc0b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -65,7 +65,7 @@ spring: - openid - profile - email - redirect-uri: ${GOOGLE_REDIRECT_URI:https://inscriptions.cdacb.in/api/login/oauth2/code/{registrationId}} + redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" # Facebook OAuth2 Configuration facebook: client-id: ${FACEBOOK_CLIENT_ID}
+ Sign in or create an account with Google +