Skip to content

Commit fa8a5c6

Browse files
committed
Fix resubmission after rejection, rename to "Update review", bump to 1.4.2
1 parent 61802f3 commit fa8a5c6

6 files changed

Lines changed: 100 additions & 47 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## 1.4.2
4+
5+
- Fix resubmission after App Review rejection – handle UNRESOLVED_ISSUES submissions to avoid ITEM_PART_OF_ANOTHER_SUBMISSION errors
6+
- Rename "Resubmit for review" button to "Update review" to match App Store Connect terminology
7+
- Revamp review insights prompt with three categories (strengths, weaknesses, potential) and stricter rules
8+
39
## 1.4.1
410

511
- Show informational banner when analytics reports are first requested – explains the 24–48 hour wait and displays elapsed time since the request was initiated

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "itsyconnect-macos",
3-
"version": "1.4.1",
3+
"version": "1.4.2",
44
"private": true,
55
"license": "AGPL-3.0-only",
66
"repository": {

src/components/layout/version-action-footer.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -376,8 +376,8 @@ function SubmitFooter({
376376
const checklistReady = useChecklistReady(version, isFirstVersion);
377377
const canSubmit = checklistReady && !hasValidationErrors && !isSaving;
378378

379-
const label = isResubmit ? "Resubmit for review" : "Submit for review";
380-
const confirmTitle = isResubmit ? "Resubmit for review?" : "Submit for review?";
379+
const label = isResubmit ? "Update review" : "Submit for review";
380+
const confirmTitle = isResubmit ? "Update review?" : "Submit for review?";
381381

382382
async function handleSubmit() {
383383
setConfirmOpen(false);
@@ -392,7 +392,7 @@ function SubmitFooter({
392392
body: JSON.stringify({ platform: version.attributes.platform }),
393393
},
394394
);
395-
toast.success("Submitted for review");
395+
toast.success(isResubmit ? "Review updated" : "Submitted for review");
396396
await delay(ASC_PROPAGATION_DELAY);
397397
} catch (err) {
398398
if (err instanceof ApiError && (err.ascErrors?.length || err.ascAssociatedErrors)) {
@@ -413,7 +413,7 @@ function SubmitFooter({
413413

414414
return (
415415
<>
416-
{loading && <LoadingOverlay label="Submitting for review…" />}
416+
{loading && <LoadingOverlay label={isResubmit ? "Updating review…" : "Submitting for review…"} />}
417417
<Footer left={<SubmissionChecklist version={version} isFirstVersion={isFirstVersion} />}>
418418
<Button disabled={!canSubmit || loading} onClick={() => setConfirmOpen(true)}>
419419
{label}
@@ -424,13 +424,15 @@ function SubmitFooter({
424424
<AlertDialogHeader>
425425
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
426426
<AlertDialogDescription>
427-
Version {version.attributes.versionString} will be submitted to App Review.
427+
{isResubmit
428+
? `Version ${version.attributes.versionString} will be resubmitted to App Review with your changes.`
429+
: `Version ${version.attributes.versionString} will be submitted to App Review.`}
428430
</AlertDialogDescription>
429431
</AlertDialogHeader>
430432
<AlertDialogFooter>
431433
<AlertDialogCancel>Cancel</AlertDialogCancel>
432434
<AlertDialogAction onClick={handleSubmit}>
433-
Submit
435+
{isResubmit ? "Update" : "Submit"}
434436
</AlertDialogAction>
435437
</AlertDialogFooter>
436438
</AlertDialogContent>

src/lib/asc/version-mutations.ts

Lines changed: 35 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -92,12 +92,18 @@ export async function submitForReview(
9292
versionId: string,
9393
platform: string,
9494
): Promise<void> {
95-
// Step 1: find an existing draft submission or create a new one.
96-
// Draft submissions (READY_FOR_REVIEW) can't be deleted or cancelled,
97-
// so we reuse them to avoid accumulating dangling drafts.
95+
// After rejection the version stays attached to an UNRESOLVED_ISSUES
96+
// submission. Re-confirming that submission resubmits it. We check for
97+
// this state first to avoid the ITEM_PART_OF_ANOTHER_SUBMISSION error.
98+
const unresolvedId = await findUnresolvedSubmission(appId);
99+
if (unresolvedId) {
100+
await confirmSubmission(unresolvedId);
101+
return;
102+
}
103+
104+
// Normal flow: find or create a READY_FOR_REVIEW submission, add item, confirm
98105
const submissionId = await findOrCreateReviewSubmission(appId, platform);
99106

100-
// Step 2: add the version as a review submission item
101107
await ascFetch("/v1/reviewSubmissionItems", {
102108
method: "POST",
103109
body: JSON.stringify({
@@ -115,7 +121,10 @@ export async function submitForReview(
115121
}),
116122
});
117123

118-
// Step 3: confirm the submission
124+
await confirmSubmission(submissionId);
125+
}
126+
127+
async function confirmSubmission(submissionId: string): Promise<void> {
119128
await ascFetch(`/v1/reviewSubmissions/${submissionId}`, {
120129
method: "PATCH",
121130
body: JSON.stringify({
@@ -128,6 +137,27 @@ export async function submitForReview(
128137
});
129138
}
130139

140+
/**
141+
* Find an UNRESOLVED_ISSUES submission for the app.
142+
* After rejection, the old submission moves to this state and still owns
143+
* the version. Re-confirming it resubmits for review.
144+
*/
145+
async function findUnresolvedSubmission(appId: string): Promise<string | null> {
146+
try {
147+
const res = await ascFetch<{
148+
data: { id: string; attributes: { state: string } }[];
149+
}>(
150+
`/v1/apps/${appId}/reviewSubmissions?filter[state]=UNRESOLVED_ISSUES`,
151+
);
152+
if (res.data.length > 0) {
153+
return res.data[0].id;
154+
}
155+
} catch {
156+
// Fall through
157+
}
158+
return null;
159+
}
160+
131161
async function findOrCreateReviewSubmission(
132162
appId: string,
133163
platform: string,

src/lib/version.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
export const APP_VERSION = "1.4.1";
2-
export const BUILD_NUMBER = "141";
1+
export const APP_VERSION = "1.4.2";
2+
export const BUILD_NUMBER = "142";

tests/unit/asc/version-mutations.test.ts

Lines changed: 48 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -141,82 +141,97 @@ describe("version-mutations", () => {
141141
});
142142

143143
describe("submitForReview", () => {
144-
it("creates submission when no draft exists, adds item, then confirms", async () => {
144+
/** Mock findUnresolvedSubmission returning no results. */
145+
function mockNoUnresolved() {
146+
mockAscFetch.mockResolvedValueOnce({ data: [] }); // no UNRESOLVED_ISSUES
147+
}
148+
149+
it("creates submission when no unresolved or draft exists", async () => {
150+
mockNoUnresolved();
145151
mockAscFetch
146-
.mockResolvedValueOnce({ data: [] }) // find drafts: none
152+
.mockResolvedValueOnce({ data: [] }) // findOrCreate: no READY_FOR_REVIEW drafts
147153
.mockResolvedValueOnce({ data: { id: "sub-1" } }) // create submission
148154
.mockResolvedValueOnce({}) // add item
149155
.mockResolvedValueOnce({}); // confirm
150156

151157
await submitForReview("app-1", "ver-1", "MAC_OS");
152158

153-
// Step 1: check for existing drafts
154-
expect(mockAscFetch.mock.calls[0][0]).toContain("/v1/apps/app-1/reviewSubmissions");
155-
156-
// Step 1b: create new submission (no draft found)
157159
expect(mockAscFetch).toHaveBeenCalledWith(
158160
"/v1/reviewSubmissions",
159161
expect.objectContaining({ method: "POST" }),
160162
);
161-
const createBody = JSON.parse(mockAscFetch.mock.calls[1][1].body);
162-
expect(createBody.data.type).toBe("reviewSubmissions");
163-
expect(createBody.data.attributes.platform).toBe("MAC_OS");
164-
expect(createBody.data.relationships.app.data.id).toBe("app-1");
165-
166-
// Step 2: add version as item
167163
expect(mockAscFetch).toHaveBeenCalledWith(
168164
"/v1/reviewSubmissionItems",
169165
expect.objectContaining({ method: "POST" }),
170166
);
171-
const itemBody = JSON.parse(mockAscFetch.mock.calls[2][1].body);
172-
expect(itemBody.data.relationships.reviewSubmission.data.id).toBe("sub-1");
173-
expect(itemBody.data.relationships.appStoreVersion.data.id).toBe("ver-1");
174167

175-
// Step 3: confirm submission
176-
const confirmBody = JSON.parse(mockAscFetch.mock.calls[3][1].body);
177-
expect(confirmBody.data.attributes.submitted).toBe(true);
168+
const lastCall = mockAscFetch.mock.calls[mockAscFetch.mock.calls.length - 1];
169+
const body = JSON.parse(lastCall[1].body);
170+
expect(body.data.attributes.submitted).toBe(true);
178171
});
179172

180-
it("reuses an existing draft submission instead of creating a new one", async () => {
173+
it("resubmits UNRESOLVED_ISSUES submission directly after rejection", async () => {
174+
mockAscFetch
175+
.mockResolvedValueOnce({ data: [
176+
{ id: "rejected-sub", attributes: { state: "UNRESOLVED_ISSUES" } },
177+
] })
178+
.mockResolvedValueOnce({}); // confirm
179+
180+
await submitForReview("app-1", "ver-1", "IOS");
181+
182+
// Should confirm the UNRESOLVED_ISSUES submission directly
183+
const confirmCall = mockAscFetch.mock.calls[1];
184+
expect(confirmCall[0]).toBe("/v1/reviewSubmissions/rejected-sub");
185+
const body = JSON.parse(confirmCall[1].body);
186+
expect(body.data.attributes.submitted).toBe(true);
187+
188+
// Should NOT create a new submission or add items
189+
expect(mockAscFetch).toHaveBeenCalledTimes(2);
190+
});
191+
192+
it("reuses an existing READY_FOR_REVIEW draft in normal flow", async () => {
193+
mockNoUnresolved();
181194
mockAscFetch
182195
.mockResolvedValueOnce({ data: [
183196
{ id: "existing-sub", attributes: { state: "READY_FOR_REVIEW" } },
184-
] }) // find drafts: one exists
197+
] })
185198
.mockResolvedValueOnce({}) // add item
186199
.mockResolvedValueOnce({}); // confirm
187200

188201
await submitForReview("app-1", "ver-1", "IOS");
189202

190-
// Should NOT create a new submission – only 3 calls total
191-
expect(mockAscFetch).toHaveBeenCalledTimes(3);
192-
193-
// Item should reference the existing submission
194-
const itemBody = JSON.parse(mockAscFetch.mock.calls[1][1].body);
203+
const addItemCall = mockAscFetch.mock.calls.find(
204+
(c: unknown[]) => c[0] === "/v1/reviewSubmissionItems" && (c[1] as Record<string, string>)?.method === "POST",
205+
)!;
206+
const itemBody = JSON.parse((addItemCall[1] as Record<string, string>).body);
195207
expect(itemBody.data.relationships.reviewSubmission.data.id).toBe("existing-sub");
196208
});
197209

198-
it("throws when step 2 (add item) fails", async () => {
210+
it("throws when add item fails", async () => {
211+
mockNoUnresolved();
199212
mockAscFetch
200-
.mockResolvedValueOnce({ data: [] }) // find drafts: none
213+
.mockResolvedValueOnce({ data: [] }) // findOrCreate: none
201214
.mockResolvedValueOnce({ data: { id: "sub-1" } }) // create
202-
.mockRejectedValueOnce(new Error("add item failed")); // add item fails
215+
.mockRejectedValueOnce(new Error("add item failed"));
203216

204217
await expect(submitForReview("app-1", "ver-1", "IOS")).rejects.toThrow("add item failed");
205218
});
206219

207-
it("throws when step 3 (confirm) fails", async () => {
220+
it("throws when confirm fails", async () => {
221+
mockNoUnresolved();
208222
mockAscFetch
209-
.mockResolvedValueOnce({ data: [] }) // find drafts: none
223+
.mockResolvedValueOnce({ data: [] }) // findOrCreate: none
210224
.mockResolvedValueOnce({ data: { id: "sub-1" } }) // create
211225
.mockResolvedValueOnce({}) // add item
212-
.mockRejectedValueOnce(new Error("confirm failed")); // confirm fails
226+
.mockRejectedValueOnce(new Error("confirm failed"));
213227

214228
await expect(submitForReview("app-1", "ver-1", "IOS")).rejects.toThrow("confirm failed");
215229
});
216230

217-
it("falls through to create when listing drafts fails", async () => {
231+
it("falls through to normal flow when unresolved search fails", async () => {
218232
mockAscFetch
219-
.mockRejectedValueOnce(new Error("list failed")) // find drafts fails
233+
.mockRejectedValueOnce(new Error("list failed")) // unresolved search fails
234+
.mockResolvedValueOnce({ data: [] }) // findOrCreate: none
220235
.mockResolvedValueOnce({ data: { id: "sub-1" } }) // create
221236
.mockResolvedValueOnce({}) // add item
222237
.mockResolvedValueOnce({}); // confirm

0 commit comments

Comments
 (0)