From 82a0054641d92a6f7c8c5223496749435bc97e7f Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Mon, 23 Mar 2026 05:52:30 +0700 Subject: [PATCH] Add version release, phased release, and rating reset settings (#5) Add interactive settings sections to the version detail page that mirror App Store Connect's release controls: - Version Release: radio group to choose Manual, After Approval, or Scheduled release with date picker - Phased Release: toggle 7-day gradual rollout with pause/resume/complete controls and progress indicator - Rating Reset: toggle to reset app summary rating on release Backend: PATCH version attributes, POST/PATCH/DELETE phased release endpoints with cache invalidation. Version detail GET now includes phased release data via JSON:API include. --- server/routes/apps.js | 152 ++++++++++++++++++++- src/api/index.js | 51 +++++++ src/components/PhasedReleaseSection.jsx | 165 +++++++++++++++++++++++ src/components/RatingResetSection.jsx | 74 ++++++++++ src/components/VersionDetailPage.jsx | 45 ++++++- src/components/VersionReleaseSection.jsx | 107 +++++++++++++++ src/constants/index.js | 10 ++ 7 files changed, 602 insertions(+), 2 deletions(-) create mode 100644 src/components/PhasedReleaseSection.jsx create mode 100644 src/components/RatingResetSection.jsx create mode 100644 src/components/VersionReleaseSection.jsx diff --git a/server/routes/apps.js b/server/routes/apps.js index b7fca0b..76b58a6 100644 --- a/server/routes/apps.js +++ b/server/routes/apps.js @@ -282,10 +282,25 @@ router.get("/:appId/versions/:versionId", async (req, res) => { try { const data = await ascFetch( account, - `/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable` + `/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable,reviewType&include=appStoreVersionPhasedRelease&fields[appStoreVersionPhasedReleases]=phasedReleaseState,currentDayNumber,startDate,totalPauseDuration` ); const attrs = data.data.attributes; + + let phasedRelease = null; + if (data.included) { + const pr = data.included.find((inc) => inc.type === "appStoreVersionPhasedReleases"); + if (pr) { + phasedRelease = { + id: pr.id, + phasedReleaseState: pr.attributes.phasedReleaseState, + currentDayNumber: pr.attributes.currentDayNumber, + startDate: pr.attributes.startDate, + totalPauseDuration: pr.attributes.totalPauseDuration, + }; + } + } + const result = { id: data.data.id, versionString: attrs.versionString, @@ -295,6 +310,8 @@ router.get("/:appId/versions/:versionId", async (req, res) => { releaseType: attrs.releaseType, earliestReleaseDate: attrs.earliestReleaseDate, downloadable: attrs.downloadable, + reviewType: attrs.reviewType, + phasedRelease, }; apiCache.set(cacheKey, result); @@ -305,6 +322,139 @@ router.get("/:appId/versions/:versionId", async (req, res) => { } }); +// ── Version Settings (release type, rating reset) ─────────────────────────── + +router.patch("/:appId/versions/:versionId", async (req, res) => { + const { versionId } = req.params; + const { accountId, releaseType, earliestReleaseDate, resetRatingSummary } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + const attributes = {}; + if (releaseType !== undefined) attributes.releaseType = releaseType; + if (earliestReleaseDate !== undefined) attributes.earliestReleaseDate = earliestReleaseDate; + if (resetRatingSummary !== undefined) attributes.resetRatingSummary = resetRatingSummary; + + try { + await ascFetch(account, `/v1/appStoreVersions/${versionId}`, { + method: "PATCH", + body: { + data: { type: "appStoreVersions", id: versionId, attributes }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ success: true }); + } catch (err) { + console.error(`Failed to update version ${versionId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +// ── Phased Release ────────────────────────────────────────────────────────── + +router.post("/:appId/versions/:versionId/phased-release", async (req, res) => { + const { versionId } = req.params; + const { accountId, phasedReleaseState } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + try { + const data = await ascFetch(account, "/v1/appStoreVersionPhasedReleases", { + method: "POST", + body: { + data: { + type: "appStoreVersionPhasedReleases", + attributes: { phasedReleaseState: phasedReleaseState || "INACTIVE" }, + relationships: { + appStoreVersion: { data: { type: "appStoreVersions", id: versionId } }, + }, + }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ + id: data.data.id, + phasedReleaseState: data.data.attributes.phasedReleaseState, + currentDayNumber: data.data.attributes.currentDayNumber, + startDate: data.data.attributes.startDate, + totalPauseDuration: data.data.attributes.totalPauseDuration, + }); + } catch (err) { + console.error(`Failed to create phased release for version ${versionId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.patch("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => { + const { versionId, phasedReleaseId } = req.params; + const { accountId, phasedReleaseState } = req.body; + + if (!accountId) { + return res.status(400).json({ error: "accountId is required" }); + } + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId); + if (!account) return res.status(400).json({ error: "Account not found" }); + + try { + const data = await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, { + method: "PATCH", + body: { + data: { + type: "appStoreVersionPhasedReleases", + id: phasedReleaseId, + attributes: { phasedReleaseState }, + }, + }, + }); + + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ + id: data.data.id, + phasedReleaseState: data.data.attributes.phasedReleaseState, + currentDayNumber: data.data.attributes.currentDayNumber, + startDate: data.data.attributes.startDate, + totalPauseDuration: data.data.attributes.totalPauseDuration, + }); + } catch (err) { + console.error(`Failed to update phased release ${phasedReleaseId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + +router.delete("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => { + const { versionId, phasedReleaseId } = req.params; + const { accountId } = req.query; + + const accounts = getAccounts(); + const account = accounts.find((a) => a.id === accountId) || accounts[0]; + if (!account) return res.status(400).json({ error: "No accounts configured" }); + + try { + await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, { method: "DELETE" }); + apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`); + res.json({ success: true }); + } catch (err) { + console.error(`Failed to delete phased release ${phasedReleaseId}:`, err.message); + res.status(502).json({ error: err.message }); + } +}); + router.get("/:appId/builds", async (req, res) => { const { appId } = req.params; const { accountId } = req.query; diff --git a/src/api/index.js b/src/api/index.js index 9d6aafb..739f3fc 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -97,6 +97,57 @@ export async function attachBuild(appId, versionId, buildId, accountId) { return res.json(); } +// ── Version Settings (release type, phased release, rating reset) ──────────── + +export async function updateVersionRelease(appId, versionId, { accountId, releaseType, earliestReleaseDate, resetRatingSummary }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, releaseType, earliestReleaseDate, resetRatingSummary }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update version release: ${res.status}`); + } + return res.json(); +} + +export async function createPhasedRelease(appId, versionId, { accountId, phasedReleaseState }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, phasedReleaseState }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to create phased release: ${res.status}`); + } + return res.json(); +} + +export async function updatePhasedRelease(appId, versionId, phasedReleaseId, { accountId, phasedReleaseState }) { + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accountId, phasedReleaseState }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to update phased release: ${res.status}`); + } + return res.json(); +} + +export async function deletePhasedRelease(appId, versionId, phasedReleaseId, accountId) { + const params = new URLSearchParams({ accountId }); + const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}?${params}`, { method: "DELETE" }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || `Failed to delete phased release: ${res.status}`); + } + return res.json(); +} + export async function fetchAppLookup(bundleId) { const params = new URLSearchParams({ bundleId }); const res = await fetch(`/api/apps/lookup?${params}`); diff --git a/src/components/PhasedReleaseSection.jsx b/src/components/PhasedReleaseSection.jsx new file mode 100644 index 0000000..60216c2 --- /dev/null +++ b/src/components/PhasedReleaseSection.jsx @@ -0,0 +1,165 @@ +import { useState } from "react"; +import { createPhasedRelease, updatePhasedRelease, deletePhasedRelease } from "../api/index.js"; +import { PHASED_RELEASE_DAY_PERCENTAGES } from "../constants/index.js"; + +export default function PhasedReleaseSection({ appId, versionId, accountId, detail, onDetailUpdate, isMobile }) { + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + const pr = detail.phasedRelease; + const hasPhased = !!pr; + const isActive = pr && (pr.phasedReleaseState === "ACTIVE" || pr.phasedReleaseState === "PAUSED"); + + async function handleToggle(wantPhased) { + setSaving(true); + setSaveError(null); + try { + if (wantPhased && !hasPhased) { + await createPhasedRelease(appId, versionId, { accountId, phasedReleaseState: "INACTIVE" }); + } else if (!wantPhased && hasPhased) { + await deletePhasedRelease(appId, versionId, pr.id, accountId); + } + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + async function handlePauseResume(newState) { + setSaving(true); + setSaveError(null); + try { + await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: newState }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + async function handleCompleteImmediately() { + setSaving(true); + setSaveError(null); + try { + await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: "COMPLETE" }); + await onDetailUpdate(); + } catch (err) { + setSaveError(err.message); + } finally { + setSaving(false); + } + } + + return ( +
+ Release this update gradually over a 7-day period to users with automatic updates enabled. + You can pause the rollout at any time. +
+ ++ You can choose to reset your app's summary rating when this version is released. + All existing ratings will be removed and a new average will be calculated from new ratings only. +
+ ++ Choose how this version is released to the App Store. You can release manually, automatically after approval, or schedule a specific date. +
+ +