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
152 changes: 151 additions & 1 deletion server/routes/apps.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
Expand All @@ -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;
Expand Down
51 changes: 51 additions & 0 deletions src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand Down
165 changes: 165 additions & 0 deletions src/components/PhasedReleaseSection.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="mb-8">
<h2 className="text-[13px] font-bold text-dark-text uppercase tracking-wide mb-3">Phased Release for Automatic Updates</h2>
<p className="text-[12px] text-dark-dim mb-4 leading-relaxed">
Release this update gradually over a 7-day period to users with automatic updates enabled.
You can pause the rollout at any time.
</p>

<div className="space-y-2.5">
<label className="flex items-center gap-3 cursor-pointer bg-dark-surface rounded-[10px] px-4 py-3 transition-colors hover:bg-dark-hover">
<input
type="radio"
name="phasedRelease"
checked={!hasPhased}
onChange={() => handleToggle(false)}
disabled={saving || isActive}
className="w-4 h-4 accent-accent shrink-0"
/>
<span className="text-[13px] text-dark-text font-medium">Release update to all users immediately</span>
{saving && !hasPhased && (
<span className="text-sm text-dark-dim shrink-0" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
)}
</label>

<label className="flex items-start gap-3 cursor-pointer bg-dark-surface rounded-[10px] px-4 py-3 transition-colors hover:bg-dark-hover">
<input
type="radio"
name="phasedRelease"
checked={hasPhased}
onChange={() => handleToggle(true)}
disabled={saving || isActive}
className="w-4 h-4 accent-accent mt-0.5 shrink-0"
/>
<div className="flex-1 min-w-0">
<span className="text-[13px] text-dark-text font-medium">Release update over a 7-day period using phased release</span>

{hasPhased && (
<div className="mt-3 space-y-2">
{/* Status badge */}
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-[11px] font-bold px-2 py-0.5 rounded ${
pr.phasedReleaseState === "ACTIVE" ? "text-[#34c759] bg-[rgba(52,199,89,0.12)]" :
pr.phasedReleaseState === "PAUSED" ? "text-[#ff9500] bg-[rgba(255,149,0,0.12)]" :
pr.phasedReleaseState === "COMPLETE" ? "text-[#34c759] bg-[rgba(52,199,89,0.12)]" :
"text-dark-dim bg-dark-hover"
}`}>
{pr.phasedReleaseState}
</span>
{pr.currentDayNumber != null && pr.phasedReleaseState !== "COMPLETE" && (
<span className="text-[11px] text-dark-dim">
Day {pr.currentDayNumber} of 7 ({PHASED_RELEASE_DAY_PERCENTAGES[pr.currentDayNumber] || "—"} of users)
</span>
)}
</div>

{/* Progress bar */}
{pr.currentDayNumber != null && pr.phasedReleaseState !== "COMPLETE" && (
<div className="w-full h-1.5 bg-dark-hover rounded-full overflow-hidden">
<div
className="h-full bg-accent rounded-full transition-all"
style={{ width: `${(pr.currentDayNumber / 7) * 100}%` }}
/>
</div>
)}

{/* Action buttons */}
{isActive && (
<div className="flex items-center gap-2 mt-1">
{pr.phasedReleaseState === "ACTIVE" && (
<button
onClick={() => handlePauseResume("PAUSED")}
disabled={saving}
className="text-[11px] font-semibold text-[#ff9500] bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
>
{saving ? "Saving..." : "Pause Rollout"}
</button>
)}
{pr.phasedReleaseState === "PAUSED" && (
<button
onClick={() => handlePauseResume("ACTIVE")}
disabled={saving}
className="text-[11px] font-semibold text-accent bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
>
{saving ? "Saving..." : "Resume Rollout"}
</button>
)}
<button
onClick={handleCompleteImmediately}
disabled={saving}
className="text-[11px] font-semibold text-dark-dim bg-transparent border-none cursor-pointer font-sans px-0 hover:underline disabled:opacity-50"
>
Release to All Users
</button>
</div>
)}
</div>
)}
</div>
{saving && hasPhased && (
<span className="text-sm text-dark-dim shrink-0" style={{ animation: "asc-spin 1s linear infinite" }}>{"\u21bb"}</span>
)}
</label>
</div>

{saveError && (
<div className="text-[11px] text-danger font-medium mt-2">{saveError}</div>
)}
</div>
);
}
Loading
Loading