Skip to content

Commit e6bcbb3

Browse files
authored
Add App Review section to app detail page (#9)
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 Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 4f2242b commit e6bcbb3

4 files changed

Lines changed: 347 additions & 0 deletions

File tree

server/routes/apps.js

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -706,6 +706,152 @@ router.delete("/:appId/versions/:versionId/localizations/:locId", async (req, re
706706
}
707707
});
708708

709+
// ── Review Submissions ──────────────────────────────────────────────────────
710+
711+
const REVIEW_STATE_DISPLAY = {
712+
WAITING_FOR_REVIEW: "Waiting for Review",
713+
IN_REVIEW: "In Review",
714+
UNRESOLVED_ISSUES: "Unresolved issues",
715+
COMPLETE: "Review Completed",
716+
CANCELING: "Removed",
717+
};
718+
719+
function buildReviewSubmissionUrl(appId, { states, limit }) {
720+
const base = `/v1/apps/${appId}/reviewSubmissions`;
721+
const params = [
722+
"include=items,appStoreVersionForReview,submittedByActor",
723+
"fields[reviewSubmissions]=submittedDate,state,platform,items,appStoreVersionForReview,submittedByActor",
724+
"fields[reviewSubmissionItems]=state,appStoreVersion",
725+
"fields[appStoreVersions]=versionString,platform",
726+
"fields[actors]=userFirstName,userLastName",
727+
`limit=${limit}`,
728+
];
729+
if (states) {
730+
params.push(`filter[state]=${states.join(",")}`);
731+
}
732+
return `${base}?${params.join("&")}`;
733+
}
734+
735+
function parseReviewSubmissions(data) {
736+
const includedMap = new Map();
737+
if (data.included) {
738+
for (const inc of data.included) {
739+
includedMap.set(`${inc.type}:${inc.id}`, inc);
740+
}
741+
}
742+
743+
return (data.data || []).map((submission) => {
744+
const attrs = submission.attributes;
745+
const state = attrs.state;
746+
747+
// Count items
748+
const itemRefs = submission.relationships?.items?.data || [];
749+
const itemCount = itemRefs.length;
750+
751+
// Resolve version string -- try appStoreVersionForReview first, then items
752+
let versions = null;
753+
const versionRef = submission.relationships?.appStoreVersionForReview?.data;
754+
if (versionRef) {
755+
const ver = includedMap.get(`${versionRef.type}:${versionRef.id}`);
756+
if (ver) {
757+
const platform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform;
758+
versions = `${platform} ${ver.attributes.versionString}`;
759+
}
760+
}
761+
762+
if (!versions && itemRefs.length > 0) {
763+
const versionStrings = new Set();
764+
for (const ref of itemRefs) {
765+
const item = includedMap.get(`${ref.type}:${ref.id}`);
766+
const itemVersionRef = item?.relationships?.appStoreVersion?.data;
767+
if (itemVersionRef) {
768+
const ver = includedMap.get(`${itemVersionRef.type}:${itemVersionRef.id}`);
769+
if (ver) {
770+
const platform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform;
771+
versionStrings.add(`${platform} ${ver.attributes.versionString}`);
772+
}
773+
}
774+
}
775+
if (versionStrings.size > 1) versions = "Multiple Versions";
776+
else if (versionStrings.size === 1) versions = [...versionStrings][0];
777+
}
778+
779+
if (itemCount > 1 && !versions) versions = "Multiple Versions";
780+
781+
// Resolve submittedBy
782+
let submittedBy = null;
783+
const actorRef = submission.relationships?.submittedByActor?.data;
784+
if (actorRef) {
785+
const actor = includedMap.get(`${actorRef.type}:${actorRef.id}`);
786+
if (actor) {
787+
submittedBy = [actor.attributes.userFirstName, actor.attributes.userLastName].filter(Boolean).join(" ");
788+
}
789+
}
790+
791+
// Derive display status: for COMPLETE submissions, check item states
792+
// If all items are REMOVED, show "Removed" instead of "Review Completed"
793+
let displayStatus = REVIEW_STATE_DISPLAY[state] || state;
794+
if (state === "COMPLETE" && itemRefs.length > 0) {
795+
const allRemoved = itemRefs.every((ref) => {
796+
const item = includedMap.get(`${ref.type}:${ref.id}`);
797+
return item?.attributes?.state === "REMOVED";
798+
});
799+
if (allRemoved) displayStatus = "Removed";
800+
}
801+
802+
return { id: submission.id, state, displayStatus, submittedDate: attrs.submittedDate, versions, submittedBy, itemCount };
803+
});
804+
}
805+
806+
router.get("/:appId/review-submissions", async (req, res) => {
807+
const { appId } = req.params;
808+
const { accountId } = req.query;
809+
810+
const cacheKey = `apps:review-submissions:${appId}:${accountId || "default"}`;
811+
const cached = apiCache.get(cacheKey);
812+
if (cached) return res.json(cached);
813+
814+
const accounts = getAccounts();
815+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
816+
817+
try {
818+
// Two parallel calls: one for unresolved messages, one for terminal submissions
819+
const [messagesData, submissionsData] = await Promise.all([
820+
ascFetch(account, buildReviewSubmissionUrl(appId, { states: ["UNRESOLVED_ISSUES"], limit: 10 })),
821+
ascFetch(account, buildReviewSubmissionUrl(appId, { states: ["COMPLETE", "CANCELING"], limit: 10 })),
822+
]);
823+
824+
const rawMessages = parseReviewSubmissions(messagesData);
825+
const rawSubmissions = parseReviewSubmissions(submissionsData);
826+
827+
const messages = rawMessages.map((m) => ({
828+
id: m.id,
829+
createdDate: m.submittedDate,
830+
versions: m.versions || "Unknown",
831+
gracePeriodEnds: null,
832+
status: m.displayStatus,
833+
}));
834+
messages.sort((a, b) => new Date(b.createdDate) - new Date(a.createdDate));
835+
836+
const submissions = rawSubmissions.map((s) => ({
837+
id: s.id,
838+
submittedDate: s.submittedDate,
839+
versions: s.versions || "Unknown",
840+
submittedBy: s.submittedBy || "Unknown",
841+
itemCount: s.itemCount === 1 ? "1 Item" : `${s.itemCount} Items`,
842+
status: s.displayStatus,
843+
}));
844+
submissions.sort((a, b) => new Date(b.submittedDate) - new Date(a.submittedDate));
845+
846+
const result = { messages, submissions: submissions.slice(0, 10) };
847+
apiCache.set(cacheKey, result);
848+
res.json(result);
849+
} catch (err) {
850+
console.error(`Failed to fetch review submissions for app ${appId}:`, err.message);
851+
res.status(502).json({ error: err.message });
852+
}
853+
});
854+
709855
router.post("/:appId/versions/:versionId/submit", async (req, res) => {
710856
const { appId, versionId } = req.params;
711857
const { accountId } = req.body;
@@ -735,6 +881,7 @@ router.post("/:appId/versions/:versionId/submit", async (req, res) => {
735881

736882
apiCache.delete("apps:list");
737883
apiCache.deleteByPrefix(`apps:versions:${appId}:`);
884+
apiCache.deleteByPrefix(`apps:review-submissions:${appId}:`);
738885

739886
res.json({ success: true, versionId });
740887
} catch (err) {

src/api/index.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,15 @@ export async function deleteVersionLocalization(appId, versionId, locId, account
200200
return res.json();
201201
}
202202

203+
// ── Review Submissions ──────────────────────────────────────────────────────
204+
205+
export async function fetchReviewSubmissions(appId, accountId) {
206+
const params = new URLSearchParams({ accountId });
207+
const res = await fetch(`/api/apps/${appId}/review-submissions?${params}`);
208+
if (!res.ok) throw new Error(`Failed to fetch review submissions: ${res.status}`);
209+
return res.json();
210+
}
211+
203212
// ── In-App Purchases ─────────────────────────────────────────────────────────
204213

205214
export async function fetchIAPs(appId, accountId) {

src/components/AppDetailPage.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { useState, useEffect } from "react";
22
import { fetchAppLookup } from "../api/index.js";
33
import AppIcon from "./AppIcon.jsx";
44
import Badge from "./Badge.jsx";
5+
import AppReviewSection from "./AppReviewSection.jsx";
56
import VersionHistory from "./VersionHistory.jsx";
67

78
function StarRating({ rating }) {
@@ -166,6 +167,9 @@ export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion
166167
</div>
167168
)}
168169

170+
{/* App Review */}
171+
<AppReviewSection appId={app.id} accountId={app.accountId} />
172+
169173
{/* Version History */}
170174
<div>
171175
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">Version History</h2>
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { useState, useEffect } from "react";
2+
import { fetchReviewSubmissions } from "../api/index.js";
3+
4+
const STATUS_COLORS = {
5+
"Unresolved issues": "#ff453a",
6+
"Review Completed": "#30d158",
7+
"Removed": "#8e8e93",
8+
"Waiting for Review": "#ff9f0a",
9+
"In Review": "#0a84ff",
10+
};
11+
12+
const STATUS_ICONS = {
13+
"Review Completed": "\u2713",
14+
"Removed": "\u2013",
15+
};
16+
17+
function formatDate(dateString) {
18+
if (!dateString) return "\u2014";
19+
const d = new Date(dateString);
20+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
21+
}
22+
23+
function ReviewStatus({ status }) {
24+
const color = STATUS_COLORS[status] || "#8e8e93";
25+
const icon = STATUS_ICONS[status];
26+
27+
return (
28+
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold">
29+
{icon ? (
30+
<span
31+
className="w-[16px] h-[16px] rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold text-white"
32+
style={{ background: color }}
33+
>
34+
{icon}
35+
</span>
36+
) : (
37+
<span className="w-[7px] h-[7px] rounded-full shrink-0" style={{ background: color }} />
38+
)}
39+
<span style={{ color }}>{status}</span>
40+
</span>
41+
);
42+
}
43+
44+
function MessagesTable({ messages }) {
45+
if (messages.length === 0) return null;
46+
47+
return (
48+
<div className="mb-6">
49+
<h3 className="text-[12px] font-bold text-dark-text uppercase tracking-wide mb-1">Messages</h3>
50+
<p className="text-[12px] text-dark-dim mb-3 mt-0">
51+
Review messages with unresolved issues that require your attention.
52+
</p>
53+
<div className="bg-dark-surface rounded-[10px] overflow-hidden">
54+
<div className="overflow-x-auto">
55+
<table className="w-full text-left border-collapse">
56+
<thead>
57+
<tr className="border-b border-dark-border">
58+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Date Created</th>
59+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Versions</th>
60+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Grace Period Ends</th>
61+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Status</th>
62+
</tr>
63+
</thead>
64+
<tbody>
65+
{messages.map((msg) => (
66+
<tr key={msg.id} className="border-b border-dark-border last:border-b-0">
67+
<td className="px-4 py-3 text-[13px] text-accent font-medium whitespace-nowrap">
68+
{formatDate(msg.createdDate)}
69+
</td>
70+
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{msg.versions}</td>
71+
<td className="px-4 py-3 text-[13px] text-dark-dim whitespace-nowrap">
72+
{msg.gracePeriodEnds ? formatDate(msg.gracePeriodEnds) : "\u2014"}
73+
</td>
74+
<td className="px-4 py-3"><ReviewStatus status={msg.status} /></td>
75+
</tr>
76+
))}
77+
</tbody>
78+
</table>
79+
</div>
80+
</div>
81+
</div>
82+
);
83+
}
84+
85+
function SubmissionsTable({ submissions }) {
86+
if (submissions.length === 0) return null;
87+
88+
return (
89+
<div>
90+
<h3 className="text-[12px] font-bold text-dark-text uppercase tracking-wide mb-1">Submissions</h3>
91+
<p className="text-[12px] text-dark-dim mb-3 mt-0">
92+
You can see the last 10 completed submissions for this app.
93+
</p>
94+
<div className="bg-dark-surface rounded-[10px] overflow-hidden">
95+
<div className="overflow-x-auto">
96+
<table className="w-full text-left border-collapse">
97+
<thead>
98+
<tr className="border-b border-dark-border">
99+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Date Submitted</th>
100+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Versions</th>
101+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Submitted By</th>
102+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Items</th>
103+
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Status</th>
104+
</tr>
105+
</thead>
106+
<tbody>
107+
{submissions.map((sub) => (
108+
<tr key={sub.id} className="border-b border-dark-border last:border-b-0">
109+
<td className="px-4 py-3 text-[13px] text-accent font-medium whitespace-nowrap">
110+
{formatDate(sub.submittedDate)}
111+
</td>
112+
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{sub.versions}</td>
113+
<td className="px-4 py-3 text-[13px] text-dark-dim whitespace-nowrap">{sub.submittedBy}</td>
114+
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{sub.itemCount}</td>
115+
<td className="px-4 py-3"><ReviewStatus status={sub.status} /></td>
116+
</tr>
117+
))}
118+
</tbody>
119+
</table>
120+
</div>
121+
</div>
122+
</div>
123+
);
124+
}
125+
126+
export default function AppReviewSection({ appId, accountId }) {
127+
const [data, setData] = useState(null);
128+
const [loading, setLoading] = useState(true);
129+
const [error, setError] = useState(null);
130+
131+
useEffect(() => {
132+
let cancelled = false;
133+
setLoading(true);
134+
setError(null);
135+
136+
fetchReviewSubmissions(appId, accountId)
137+
.then((result) => { if (!cancelled) setData(result); })
138+
.catch((err) => { if (!cancelled) setError(err.message); })
139+
.finally(() => { if (!cancelled) setLoading(false); });
140+
141+
return () => { cancelled = true; };
142+
}, [appId, accountId]);
143+
144+
if (loading) {
145+
return (
146+
<div className="mb-8">
147+
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
148+
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
149+
<span className="text-[12px] text-dark-dim">Loading review submissions...</span>
150+
</div>
151+
</div>
152+
);
153+
}
154+
155+
if (error) {
156+
return (
157+
<div className="mb-8">
158+
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
159+
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
160+
<span className="text-[12px] text-dark-dim">Failed to load review submissions.</span>
161+
</div>
162+
</div>
163+
);
164+
}
165+
166+
const hasMessages = data?.messages?.length > 0;
167+
const hasSubmissions = data?.submissions?.length > 0;
168+
169+
if (!hasMessages && !hasSubmissions) {
170+
return (
171+
<div className="mb-8">
172+
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
173+
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
174+
<span className="text-[12px] text-dark-dim">No review submissions found.</span>
175+
</div>
176+
</div>
177+
);
178+
}
179+
180+
return (
181+
<div className="mb-8">
182+
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
183+
{hasMessages && <MessagesTable messages={data.messages} />}
184+
{hasSubmissions && <SubmissionsTable submissions={data.submissions} />}
185+
</div>
186+
);
187+
}

0 commit comments

Comments
 (0)