Skip to content

Commit 06adfbf

Browse files
authored
Add version release, phased release, and rating reset settings (#5) (#7)
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. Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent 5f5ba95 commit 06adfbf

7 files changed

Lines changed: 602 additions & 2 deletions

File tree

server/routes/apps.js

Lines changed: 151 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,10 +282,25 @@ router.get("/:appId/versions/:versionId", async (req, res) => {
282282
try {
283283
const data = await ascFetch(
284284
account,
285-
`/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable`
285+
`/v1/appStoreVersions/${versionId}?fields[appStoreVersions]=versionString,appStoreState,platform,createdDate,releaseType,earliestReleaseDate,downloadable,reviewType&include=appStoreVersionPhasedRelease&fields[appStoreVersionPhasedReleases]=phasedReleaseState,currentDayNumber,startDate,totalPauseDuration`
286286
);
287287

288288
const attrs = data.data.attributes;
289+
290+
let phasedRelease = null;
291+
if (data.included) {
292+
const pr = data.included.find((inc) => inc.type === "appStoreVersionPhasedReleases");
293+
if (pr) {
294+
phasedRelease = {
295+
id: pr.id,
296+
phasedReleaseState: pr.attributes.phasedReleaseState,
297+
currentDayNumber: pr.attributes.currentDayNumber,
298+
startDate: pr.attributes.startDate,
299+
totalPauseDuration: pr.attributes.totalPauseDuration,
300+
};
301+
}
302+
}
303+
289304
const result = {
290305
id: data.data.id,
291306
versionString: attrs.versionString,
@@ -295,6 +310,8 @@ router.get("/:appId/versions/:versionId", async (req, res) => {
295310
releaseType: attrs.releaseType,
296311
earliestReleaseDate: attrs.earliestReleaseDate,
297312
downloadable: attrs.downloadable,
313+
reviewType: attrs.reviewType,
314+
phasedRelease,
298315
};
299316

300317
apiCache.set(cacheKey, result);
@@ -305,6 +322,139 @@ router.get("/:appId/versions/:versionId", async (req, res) => {
305322
}
306323
});
307324

325+
// ── Version Settings (release type, rating reset) ───────────────────────────
326+
327+
router.patch("/:appId/versions/:versionId", async (req, res) => {
328+
const { versionId } = req.params;
329+
const { accountId, releaseType, earliestReleaseDate, resetRatingSummary } = req.body;
330+
331+
if (!accountId) {
332+
return res.status(400).json({ error: "accountId is required" });
333+
}
334+
335+
const accounts = getAccounts();
336+
const account = accounts.find((a) => a.id === accountId);
337+
if (!account) return res.status(400).json({ error: "Account not found" });
338+
339+
const attributes = {};
340+
if (releaseType !== undefined) attributes.releaseType = releaseType;
341+
if (earliestReleaseDate !== undefined) attributes.earliestReleaseDate = earliestReleaseDate;
342+
if (resetRatingSummary !== undefined) attributes.resetRatingSummary = resetRatingSummary;
343+
344+
try {
345+
await ascFetch(account, `/v1/appStoreVersions/${versionId}`, {
346+
method: "PATCH",
347+
body: {
348+
data: { type: "appStoreVersions", id: versionId, attributes },
349+
},
350+
});
351+
352+
apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`);
353+
res.json({ success: true });
354+
} catch (err) {
355+
console.error(`Failed to update version ${versionId}:`, err.message);
356+
res.status(502).json({ error: err.message });
357+
}
358+
});
359+
360+
// ── Phased Release ──────────────────────────────────────────────────────────
361+
362+
router.post("/:appId/versions/:versionId/phased-release", async (req, res) => {
363+
const { versionId } = req.params;
364+
const { accountId, phasedReleaseState } = req.body;
365+
366+
if (!accountId) {
367+
return res.status(400).json({ error: "accountId is required" });
368+
}
369+
370+
const accounts = getAccounts();
371+
const account = accounts.find((a) => a.id === accountId);
372+
if (!account) return res.status(400).json({ error: "Account not found" });
373+
374+
try {
375+
const data = await ascFetch(account, "/v1/appStoreVersionPhasedReleases", {
376+
method: "POST",
377+
body: {
378+
data: {
379+
type: "appStoreVersionPhasedReleases",
380+
attributes: { phasedReleaseState: phasedReleaseState || "INACTIVE" },
381+
relationships: {
382+
appStoreVersion: { data: { type: "appStoreVersions", id: versionId } },
383+
},
384+
},
385+
},
386+
});
387+
388+
apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`);
389+
res.json({
390+
id: data.data.id,
391+
phasedReleaseState: data.data.attributes.phasedReleaseState,
392+
currentDayNumber: data.data.attributes.currentDayNumber,
393+
startDate: data.data.attributes.startDate,
394+
totalPauseDuration: data.data.attributes.totalPauseDuration,
395+
});
396+
} catch (err) {
397+
console.error(`Failed to create phased release for version ${versionId}:`, err.message);
398+
res.status(502).json({ error: err.message });
399+
}
400+
});
401+
402+
router.patch("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => {
403+
const { versionId, phasedReleaseId } = req.params;
404+
const { accountId, phasedReleaseState } = req.body;
405+
406+
if (!accountId) {
407+
return res.status(400).json({ error: "accountId is required" });
408+
}
409+
410+
const accounts = getAccounts();
411+
const account = accounts.find((a) => a.id === accountId);
412+
if (!account) return res.status(400).json({ error: "Account not found" });
413+
414+
try {
415+
const data = await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, {
416+
method: "PATCH",
417+
body: {
418+
data: {
419+
type: "appStoreVersionPhasedReleases",
420+
id: phasedReleaseId,
421+
attributes: { phasedReleaseState },
422+
},
423+
},
424+
});
425+
426+
apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`);
427+
res.json({
428+
id: data.data.id,
429+
phasedReleaseState: data.data.attributes.phasedReleaseState,
430+
currentDayNumber: data.data.attributes.currentDayNumber,
431+
startDate: data.data.attributes.startDate,
432+
totalPauseDuration: data.data.attributes.totalPauseDuration,
433+
});
434+
} catch (err) {
435+
console.error(`Failed to update phased release ${phasedReleaseId}:`, err.message);
436+
res.status(502).json({ error: err.message });
437+
}
438+
});
439+
440+
router.delete("/:appId/versions/:versionId/phased-release/:phasedReleaseId", async (req, res) => {
441+
const { versionId, phasedReleaseId } = req.params;
442+
const { accountId } = req.query;
443+
444+
const accounts = getAccounts();
445+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
446+
if (!account) return res.status(400).json({ error: "No accounts configured" });
447+
448+
try {
449+
await ascFetch(account, `/v1/appStoreVersionPhasedReleases/${phasedReleaseId}`, { method: "DELETE" });
450+
apiCache.deleteByPrefix(`apps:version-detail:${versionId}:`);
451+
res.json({ success: true });
452+
} catch (err) {
453+
console.error(`Failed to delete phased release ${phasedReleaseId}:`, err.message);
454+
res.status(502).json({ error: err.message });
455+
}
456+
});
457+
308458
router.get("/:appId/builds", async (req, res) => {
309459
const { appId } = req.params;
310460
const { accountId } = req.query;

src/api/index.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,57 @@ export async function attachBuild(appId, versionId, buildId, accountId) {
9797
return res.json();
9898
}
9999

100+
// ── Version Settings (release type, phased release, rating reset) ────────────
101+
102+
export async function updateVersionRelease(appId, versionId, { accountId, releaseType, earliestReleaseDate, resetRatingSummary }) {
103+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}`, {
104+
method: "PATCH",
105+
headers: { "Content-Type": "application/json" },
106+
body: JSON.stringify({ accountId, releaseType, earliestReleaseDate, resetRatingSummary }),
107+
});
108+
if (!res.ok) {
109+
const err = await res.json().catch(() => ({}));
110+
throw new Error(err.error || `Failed to update version release: ${res.status}`);
111+
}
112+
return res.json();
113+
}
114+
115+
export async function createPhasedRelease(appId, versionId, { accountId, phasedReleaseState }) {
116+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release`, {
117+
method: "POST",
118+
headers: { "Content-Type": "application/json" },
119+
body: JSON.stringify({ accountId, phasedReleaseState }),
120+
});
121+
if (!res.ok) {
122+
const err = await res.json().catch(() => ({}));
123+
throw new Error(err.error || `Failed to create phased release: ${res.status}`);
124+
}
125+
return res.json();
126+
}
127+
128+
export async function updatePhasedRelease(appId, versionId, phasedReleaseId, { accountId, phasedReleaseState }) {
129+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}`, {
130+
method: "PATCH",
131+
headers: { "Content-Type": "application/json" },
132+
body: JSON.stringify({ accountId, phasedReleaseState }),
133+
});
134+
if (!res.ok) {
135+
const err = await res.json().catch(() => ({}));
136+
throw new Error(err.error || `Failed to update phased release: ${res.status}`);
137+
}
138+
return res.json();
139+
}
140+
141+
export async function deletePhasedRelease(appId, versionId, phasedReleaseId, accountId) {
142+
const params = new URLSearchParams({ accountId });
143+
const res = await fetch(`/api/apps/${appId}/versions/${versionId}/phased-release/${phasedReleaseId}?${params}`, { method: "DELETE" });
144+
if (!res.ok) {
145+
const err = await res.json().catch(() => ({}));
146+
throw new Error(err.error || `Failed to delete phased release: ${res.status}`);
147+
}
148+
return res.json();
149+
}
150+
100151
export async function fetchAppLookup(bundleId) {
101152
const params = new URLSearchParams({ bundleId });
102153
const res = await fetch(`/api/apps/lookup?${params}`);
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import { useState } from "react";
2+
import { createPhasedRelease, updatePhasedRelease, deletePhasedRelease } from "../api/index.js";
3+
import { PHASED_RELEASE_DAY_PERCENTAGES } from "../constants/index.js";
4+
5+
export default function PhasedReleaseSection({ appId, versionId, accountId, detail, onDetailUpdate, isMobile }) {
6+
const [saving, setSaving] = useState(false);
7+
const [saveError, setSaveError] = useState(null);
8+
9+
const pr = detail.phasedRelease;
10+
const hasPhased = !!pr;
11+
const isActive = pr && (pr.phasedReleaseState === "ACTIVE" || pr.phasedReleaseState === "PAUSED");
12+
13+
async function handleToggle(wantPhased) {
14+
setSaving(true);
15+
setSaveError(null);
16+
try {
17+
if (wantPhased && !hasPhased) {
18+
await createPhasedRelease(appId, versionId, { accountId, phasedReleaseState: "INACTIVE" });
19+
} else if (!wantPhased && hasPhased) {
20+
await deletePhasedRelease(appId, versionId, pr.id, accountId);
21+
}
22+
await onDetailUpdate();
23+
} catch (err) {
24+
setSaveError(err.message);
25+
} finally {
26+
setSaving(false);
27+
}
28+
}
29+
30+
async function handlePauseResume(newState) {
31+
setSaving(true);
32+
setSaveError(null);
33+
try {
34+
await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: newState });
35+
await onDetailUpdate();
36+
} catch (err) {
37+
setSaveError(err.message);
38+
} finally {
39+
setSaving(false);
40+
}
41+
}
42+
43+
async function handleCompleteImmediately() {
44+
setSaving(true);
45+
setSaveError(null);
46+
try {
47+
await updatePhasedRelease(appId, versionId, pr.id, { accountId, phasedReleaseState: "COMPLETE" });
48+
await onDetailUpdate();
49+
} catch (err) {
50+
setSaveError(err.message);
51+
} finally {
52+
setSaving(false);
53+
}
54+
}
55+
56+
return (
57+
<div className="mb-8">
58+
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">Phased Release for Automatic Updates</h2>
59+
<p className="text-[12px] text-dark-dim mb-4 leading-relaxed">
60+
Release this update gradually over a 7-day period to users with automatic updates enabled.
61+
You can pause the rollout at any time.
62+
</p>
63+
64+
<div className="space-y-2.5">
65+
<label className="flex items-center gap-3 cursor-pointer bg-dark-surface rounded-[10px] px-4 py-3 transition-colors hover:bg-dark-hover">
66+
<input
67+
type="radio"
68+
name="phasedRelease"
69+
checked={!hasPhased}
70+
onChange={() => handleToggle(false)}
71+
disabled={saving || isActive}
72+
className="w-4 h-4 accent-accent shrink-0"
73+
/>
74+
<span className="text-[13px] text-dark-text font-medium">Release update to all users immediately</span>
75+
{saving && !hasPhased && (
76+
<span className="text-sm text-dark-dim shrink-0" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
77+
)}
78+
</label>
79+
80+
<label className="flex items-start gap-3 cursor-pointer bg-dark-surface rounded-[10px] px-4 py-3 transition-colors hover:bg-dark-hover">
81+
<input
82+
type="radio"
83+
name="phasedRelease"
84+
checked={hasPhased}
85+
onChange={() => handleToggle(true)}
86+
disabled={saving || isActive}
87+
className="w-4 h-4 accent-accent mt-0.5 shrink-0"
88+
/>
89+
<div className="flex-1 min-w-0">
90+
<span className="text-[13px] text-dark-text font-medium">Release update over a 7-day period using phased release</span>
91+
92+
{hasPhased && (
93+
<div className="mt-3 space-y-2">
94+
{/* Status badge */}
95+
<div className="flex items-center gap-2 flex-wrap">
96+
<span className={`text-[11px] font-bold px-2 py-0.5 rounded ${
97+
pr.phasedReleaseState === "ACTIVE" ? "text-[#34c759] bg-[rgba(52,199,89,0.12)]" :
98+
pr.phasedReleaseState === "PAUSED" ? "text-[#ff9500] bg-[rgba(255,149,0,0.12)]" :
99+
pr.phasedReleaseState === "COMPLETE" ? "text-[#34c759] bg-[rgba(52,199,89,0.12)]" :
100+
"text-dark-dim bg-dark-hover"
101+
}`}>
102+
{pr.phasedReleaseState}
103+
</span>
104+
{pr.currentDayNumber != null && pr.phasedReleaseState !== "COMPLETE" && (
105+
<span className="text-[11px] text-dark-dim">
106+
Day {pr.currentDayNumber} of 7 ({PHASED_RELEASE_DAY_PERCENTAGES[pr.currentDayNumber] || "—"} of users)
107+
</span>
108+
)}
109+
</div>
110+
111+
{/* Progress bar */}
112+
{pr.currentDayNumber != null && pr.phasedReleaseState !== "COMPLETE" && (
113+
<div className="w-full h-1.5 bg-dark-hover rounded-full overflow-hidden">
114+
<div
115+
className="h-full bg-accent rounded-full transition-all"
116+
style={{ width: `${(pr.currentDayNumber / 7) * 100}%` }}
117+
/>
118+
</div>
119+
)}
120+
121+
{/* Action buttons */}
122+
{isActive && (
123+
<div className="flex items-center gap-2 mt-1">
124+
{pr.phasedReleaseState === "ACTIVE" && (
125+
<button
126+
onClick={() => handlePauseResume("PAUSED")}
127+
disabled={saving}
128+
className="text-[11px] font-semibold text-[#ff9500] bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
129+
>
130+
{saving ? "Saving..." : "Pause Rollout"}
131+
</button>
132+
)}
133+
{pr.phasedReleaseState === "PAUSED" && (
134+
<button
135+
onClick={() => handlePauseResume("ACTIVE")}
136+
disabled={saving}
137+
className="text-[11px] font-semibold text-accent bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
138+
>
139+
{saving ? "Saving..." : "Resume Rollout"}
140+
</button>
141+
)}
142+
<button
143+
onClick={handleCompleteImmediately}
144+
disabled={saving}
145+
className="text-[11px] font-semibold text-dark-dim bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
146+
>
147+
Release to All Users
148+
</button>
149+
</div>
150+
)}
151+
</div>
152+
)}
153+
</div>
154+
{saving && hasPhased && (
155+
<span className="text-sm text-dark-dim shrink-0" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
156+
)}
157+
</label>
158+
</div>
159+
160+
{saveError && (
161+
<div className="text-[11px] text-danger font-medium mt-2">{saveError}</div>
162+
)}
163+
</div>
164+
);
165+
}

0 commit comments

Comments
 (0)