From d78a725a987d94d988fad422ff6d52e21e7ee355 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Tue, 24 Mar 2026 07:27:57 +0700 Subject: [PATCH] Add App Review section to app detail page Add a new App Review section between App Store Info and Version History on the app detail page, showing review submission history from the ASC API. Backend: - New GET /api/apps/:appId/review-submissions endpoint - Fetches from /v1/apps/{id}/reviewSubmissions with two parallel calls: one filtered for UNRESOLVED_ISSUES (messages), one for COMPLETE/CANCELING (submissions) - Normalizes JSON:API response resolving version strings, actor names, and item counts from included relationships - Derives "Removed" status from item states when all items are REMOVED - 5-min cache with invalidation on submit-for-review Frontend: - New AppReviewSection component with Messages and Submissions tables - fetchReviewSubmissions API client function - Loading, error, and empty states handled gracefully --- server/routes/apps.js | 147 ++++++++++++++++++++++ src/api/index.js | 9 ++ src/components/AppDetailPage.jsx | 4 + src/components/AppReviewSection.jsx | 187 ++++++++++++++++++++++++++++ 4 files changed, 347 insertions(+) create mode 100644 src/components/AppReviewSection.jsx diff --git a/server/routes/apps.js b/server/routes/apps.js index 76b58a6..f57f964 100644 --- a/server/routes/apps.js +++ b/server/routes/apps.js @@ -706,6 +706,152 @@ router.delete("/:appId/versions/:versionId/localizations/:locId", async (req, re } }); +// ── Review Submissions ────────────────────────────────────────────────────── + +const REVIEW_STATE_DISPLAY = { + WAITING_FOR_REVIEW: "Waiting for Review", + IN_REVIEW: "In Review", + UNRESOLVED_ISSUES: "Unresolved issues", + COMPLETE: "Review Completed", + CANCELING: "Removed", +}; + +function buildReviewSubmissionUrl(appId, { states, limit }) { + const base = `/v1/apps/${appId}/reviewSubmissions`; + const params = [ + "include=items,appStoreVersionForReview,submittedByActor", + "fields[reviewSubmissions]=submittedDate,state,platform,items,appStoreVersionForReview,submittedByActor", + "fields[reviewSubmissionItems]=state,appStoreVersion", + "fields[appStoreVersions]=versionString,platform", + "fields[actors]=userFirstName,userLastName", + `limit=${limit}`, + ]; + if (states) { + params.push(`filter[state]=${states.join(",")}`); + } + return `${base}?${params.join("&")}`; +} + +function parseReviewSubmissions(data) { + const includedMap = new Map(); + if (data.included) { + for (const inc of data.included) { + includedMap.set(`${inc.type}:${inc.id}`, inc); + } + } + + return (data.data || []).map((submission) => { + const attrs = submission.attributes; + const state = attrs.state; + + // Count items + const itemRefs = submission.relationships?.items?.data || []; + const itemCount = itemRefs.length; + + // Resolve version string -- try appStoreVersionForReview first, then items + let versions = null; + const versionRef = submission.relationships?.appStoreVersionForReview?.data; + if (versionRef) { + const ver = includedMap.get(`${versionRef.type}:${versionRef.id}`); + if (ver) { + const platform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform; + versions = `${platform} ${ver.attributes.versionString}`; + } + } + + if (!versions && itemRefs.length > 0) { + const versionStrings = new Set(); + for (const ref of itemRefs) { + const item = includedMap.get(`${ref.type}:${ref.id}`); + const itemVersionRef = item?.relationships?.appStoreVersion?.data; + if (itemVersionRef) { + const ver = includedMap.get(`${itemVersionRef.type}:${itemVersionRef.id}`); + if (ver) { + const platform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform; + versionStrings.add(`${platform} ${ver.attributes.versionString}`); + } + } + } + if (versionStrings.size > 1) versions = "Multiple Versions"; + else if (versionStrings.size === 1) versions = [...versionStrings][0]; + } + + if (itemCount > 1 && !versions) versions = "Multiple Versions"; + + // Resolve submittedBy + let submittedBy = null; + const actorRef = submission.relationships?.submittedByActor?.data; + if (actorRef) { + const actor = includedMap.get(`${actorRef.type}:${actorRef.id}`); + if (actor) { + submittedBy = [actor.attributes.userFirstName, actor.attributes.userLastName].filter(Boolean).join(" "); + } + } + + // Derive display status: for COMPLETE submissions, check item states + // If all items are REMOVED, show "Removed" instead of "Review Completed" + let displayStatus = REVIEW_STATE_DISPLAY[state] || state; + if (state === "COMPLETE" && itemRefs.length > 0) { + const allRemoved = itemRefs.every((ref) => { + const item = includedMap.get(`${ref.type}:${ref.id}`); + return item?.attributes?.state === "REMOVED"; + }); + if (allRemoved) displayStatus = "Removed"; + } + + return { id: submission.id, state, displayStatus, submittedDate: attrs.submittedDate, versions, submittedBy, itemCount }; + }); +} + +router.get("/:appId/review-submissions", async (req, res) => { + const { appId } = req.params; + const { accountId } = req.query; + + const cacheKey = `apps:review-submissions:${appId}:${accountId || "default"}`; + const cached = apiCache.get(cacheKey); + if (cached) return res.json(cached); + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId) || accounts[0]; + + try { + // Two parallel calls: one for unresolved messages, one for terminal submissions + const [messagesData, submissionsData] = await Promise.all([ + ascFetch(account, buildReviewSubmissionUrl(appId, { states: ["UNRESOLVED_ISSUES"], limit: 10 })), + ascFetch(account, buildReviewSubmissionUrl(appId, { states: ["COMPLETE", "CANCELING"], limit: 10 })), + ]); + + const rawMessages = parseReviewSubmissions(messagesData); + const rawSubmissions = parseReviewSubmissions(submissionsData); + + const messages = rawMessages.map((m) => ({ + id: m.id, + createdDate: m.submittedDate, + versions: m.versions || "Unknown", + gracePeriodEnds: null, + status: m.displayStatus, + })); + messages.sort((a, b) => new Date(b.createdDate) - new Date(a.createdDate)); + + const submissions = rawSubmissions.map((s) => ({ + id: s.id, + submittedDate: s.submittedDate, + versions: s.versions || "Unknown", + submittedBy: s.submittedBy || "Unknown", + itemCount: s.itemCount === 1 ? "1 Item" : `${s.itemCount} Items`, + status: s.displayStatus, + })); + submissions.sort((a, b) => new Date(b.submittedDate) - new Date(a.submittedDate)); + + const result = { messages, submissions: submissions.slice(0, 10) }; + apiCache.set(cacheKey, result); + res.json(result); + } catch (err) { + console.error(`Failed to fetch review submissions for app ${appId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + router.post("/:appId/versions/:versionId/submit", async (req, res) => { const { appId, versionId } = req.params; const { accountId } = req.body; @@ -735,6 +881,7 @@ router.post("/:appId/versions/:versionId/submit", async (req, res) => { apiCache.delete("apps:list"); apiCache.deleteByPrefix(`apps:versions:${appId}:`); + apiCache.deleteByPrefix(`apps:review-submissions:${appId}:`); res.json({ success: true, versionId }); } catch (err) { diff --git a/src/api/index.js b/src/api/index.js index a3db5d2..afc4c97 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -200,6 +200,15 @@ export async function deleteVersionLocalization(appId, versionId, locId, account return res.json(); } +// ── Review Submissions ────────────────────────────────────────────────────── + +export async function fetchReviewSubmissions(appId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/review-submissions?${params}`); + if (!res.ok) throw new Error(`Failed to fetch review submissions: ${res.status}`); + return res.json(); +} + // ── In-App Purchases ───────────────────────────────────────────────────────── export async function fetchIAPs(appId, accountId) { diff --git a/src/components/AppDetailPage.jsx b/src/components/AppDetailPage.jsx index 2719cbd..d578d1f 100644 --- a/src/components/AppDetailPage.jsx +++ b/src/components/AppDetailPage.jsx @@ -2,6 +2,7 @@ import { useState, useEffect } from "react"; import { fetchAppLookup } from "../api/index.js"; import AppIcon from "./AppIcon.jsx"; import Badge from "./Badge.jsx"; +import AppReviewSection from "./AppReviewSection.jsx"; import VersionHistory from "./VersionHistory.jsx"; function StarRating({ rating }) { @@ -166,6 +167,9 @@ export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion )} + {/* App Review */} + + {/* Version History */}

Version History

diff --git a/src/components/AppReviewSection.jsx b/src/components/AppReviewSection.jsx new file mode 100644 index 0000000..358cf71 --- /dev/null +++ b/src/components/AppReviewSection.jsx @@ -0,0 +1,187 @@ +import { useState, useEffect } from "react"; +import { fetchReviewSubmissions } from "../api/index.js"; + +const STATUS_COLORS = { + "Unresolved issues": "#ff453a", + "Review Completed": "#30d158", + "Removed": "#8e8e93", + "Waiting for Review": "#ff9f0a", + "In Review": "#0a84ff", +}; + +const STATUS_ICONS = { + "Review Completed": "\u2713", + "Removed": "\u2013", +}; + +function formatDate(dateString) { + if (!dateString) return "\u2014"; + const d = new Date(dateString); + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); +} + +function ReviewStatus({ status }) { + const color = STATUS_COLORS[status] || "#8e8e93"; + const icon = STATUS_ICONS[status]; + + return ( + + {icon ? ( + + {icon} + + ) : ( + + )} + {status} + + ); +} + +function MessagesTable({ messages }) { + if (messages.length === 0) return null; + + return ( +
+

Messages

+

+ Review messages with unresolved issues that require your attention. +

+
+
+ + + + + + + + + + + {messages.map((msg) => ( + + + + + + + ))} + +
Date CreatedVersionsGrace Period EndsStatus
+ {formatDate(msg.createdDate)} + {msg.versions} + {msg.gracePeriodEnds ? formatDate(msg.gracePeriodEnds) : "\u2014"} +
+
+
+
+ ); +} + +function SubmissionsTable({ submissions }) { + if (submissions.length === 0) return null; + + return ( +
+

Submissions

+

+ You can see the last 10 completed submissions for this app. +

+
+
+ + + + + + + + + + + + {submissions.map((sub) => ( + + + + + + + + ))} + +
Date SubmittedVersionsSubmitted ByItemsStatus
+ {formatDate(sub.submittedDate)} + {sub.versions}{sub.submittedBy}{sub.itemCount}
+
+
+
+ ); +} + +export default function AppReviewSection({ appId, accountId }) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + + fetchReviewSubmissions(appId, accountId) + .then((result) => { if (!cancelled) setData(result); }) + .catch((err) => { if (!cancelled) setError(err.message); }) + .finally(() => { if (!cancelled) setLoading(false); }); + + return () => { cancelled = true; }; + }, [appId, accountId]); + + if (loading) { + return ( +
+

App Review

+
+ Loading review submissions... +
+
+ ); + } + + if (error) { + return ( +
+

App Review

+
+ Failed to load review submissions. +
+
+ ); + } + + const hasMessages = data?.messages?.length > 0; + const hasSubmissions = data?.submissions?.length > 0; + + if (!hasMessages && !hasSubmissions) { + return ( +
+

App Review

+
+ No review submissions found. +
+
+ ); + } + + return ( +
+

App Review

+ {hasMessages && } + {hasSubmissions && } +
+ ); +}