Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions server/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 4 additions & 0 deletions src/components/AppDetailPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -166,6 +167,9 @@ export default function AppDetailPage({ app, accounts, isMobile, onSelectVersion
</div>
)}

{/* App Review */}
<AppReviewSection appId={app.id} accountId={app.accountId} />

{/* Version History */}
<div>
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">Version History</h2>
Expand Down
187 changes: 187 additions & 0 deletions src/components/AppReviewSection.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<span className="inline-flex items-center gap-1.5 text-[11px] font-semibold">
{icon ? (
<span
className="w-[16px] h-[16px] rounded-full shrink-0 flex items-center justify-center text-[10px] font-bold text-white"
style={{ background: color }}
>
{icon}
</span>
) : (
<span className="w-[7px] h-[7px] rounded-full shrink-0" style={{ background: color }} />
)}
<span style={{ color }}>{status}</span>
</span>
);
}

function MessagesTable({ messages }) {
if (messages.length === 0) return null;

return (
<div className="mb-6">
<h3 className="text-[12px] font-bold text-dark-text uppercase tracking-wide mb-1">Messages</h3>
<p className="text-[12px] text-dark-dim mb-3 mt-0">
Review messages with unresolved issues that require your attention.
</p>
<div className="bg-dark-surface rounded-[10px] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-dark-border">
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Date Created</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Versions</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Grace Period Ends</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Status</th>
</tr>
</thead>
<tbody>
{messages.map((msg) => (
<tr key={msg.id} className="border-b border-dark-border last:border-b-0">
<td className="px-4 py-3 text-[13px] text-accent font-medium whitespace-nowrap">
{formatDate(msg.createdDate)}
</td>
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{msg.versions}</td>
<td className="px-4 py-3 text-[13px] text-dark-dim whitespace-nowrap">
{msg.gracePeriodEnds ? formatDate(msg.gracePeriodEnds) : "\u2014"}
</td>
<td className="px-4 py-3"><ReviewStatus status={msg.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

function SubmissionsTable({ submissions }) {
if (submissions.length === 0) return null;

return (
<div>
<h3 className="text-[12px] font-bold text-dark-text uppercase tracking-wide mb-1">Submissions</h3>
<p className="text-[12px] text-dark-dim mb-3 mt-0">
You can see the last 10 completed submissions for this app.
</p>
<div className="bg-dark-surface rounded-[10px] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-dark-border">
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Date Submitted</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Versions</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Submitted By</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Items</th>
<th className="text-[10px] font-bold text-dark-dim uppercase tracking-wide px-4 py-2.5">Status</th>
</tr>
</thead>
<tbody>
{submissions.map((sub) => (
<tr key={sub.id} className="border-b border-dark-border last:border-b-0">
<td className="px-4 py-3 text-[13px] text-accent font-medium whitespace-nowrap">
{formatDate(sub.submittedDate)}
</td>
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{sub.versions}</td>
<td className="px-4 py-3 text-[13px] text-dark-dim whitespace-nowrap">{sub.submittedBy}</td>
<td className="px-4 py-3 text-[13px] text-dark-text whitespace-nowrap">{sub.itemCount}</td>
<td className="px-4 py-3"><ReviewStatus status={sub.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}

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 (
<div className="mb-8">
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
<span className="text-[12px] text-dark-dim">Loading review submissions...</span>
</div>
</div>
);
}

if (error) {
return (
<div className="mb-8">
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
<span className="text-[12px] text-dark-dim">Failed to load review submissions.</span>
</div>
</div>
);
}

const hasMessages = data?.messages?.length > 0;
const hasSubmissions = data?.submissions?.length > 0;

if (!hasMessages && !hasSubmissions) {
return (
<div className="mb-8">
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
<div className="bg-dark-surface rounded-[10px] px-4 py-6 text-center">
<span className="text-[12px] text-dark-dim">No review submissions found.</span>
</div>
</div>
);
}

return (
<div className="mb-8">
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">App Review</h2>
{hasMessages && <MessagesTable messages={data.messages} />}
{hasSubmissions && <SubmissionsTable submissions={data.submissions} />}
</div>
);
}
Loading