Skip to content

Commit 7c084e8

Browse files
authored
Rewrite export compliance to use correct ASC API flow (#12)
- Surface compliance status in BuildSelector for all builds, not just when compliance is missing (always show Edit/Manage button) - Enrich attached build with complianceState from builds list - Remove deprecated usesNonExemptEncryption from declaration API calls - Rewrite PATCH handler to use correct ASC API approach: - "No encryption": PATCH build with usesNonExemptEncryption=false - "Yes + algorithm": POST new declaration, then PATCH build to link it - Include usesNonExemptEncryption in build fields and derive compliance state as VALID when explicitly set to false Co-authored-by: Quang Tran <16215255+trmquang93@users.noreply.github.com>
1 parent ab5b95b commit 7c084e8

4 files changed

Lines changed: 275 additions & 54 deletions

File tree

server/routes/apps.js

Lines changed: 237 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -467,8 +467,8 @@ router.get("/:appId/builds", async (req, res) => {
467467
const account = accounts.find((a) => a.id === accountId) || accounts[0];
468468

469469
try {
470-
const fields = "fields[builds]=version,processingState,uploadedDate,iconAssetToken,minOsVersion,buildAudienceType";
471-
const encryptionInclude = "include=appEncryptionDeclaration&fields[appEncryptionDeclarations]=usesNonExemptEncryption,appEncryptionDeclarationState";
470+
const fields = "fields[builds]=version,processingState,uploadedDate,iconAssetToken,minOsVersion,buildAudienceType,usesNonExemptEncryption";
471+
const encryptionInclude = "include=appEncryptionDeclaration&fields[appEncryptionDeclarations]=appEncryptionDeclarationState";
472472
let url;
473473
if (versionString) {
474474
url = `/v1/builds?filter[app]=${appId}&filter[preReleaseVersion.version]=${encodeURIComponent(versionString)}&${fields}&${encryptionInclude}&limit=25`;
@@ -497,6 +497,11 @@ router.get("/:appId/builds", async (req, res) => {
497497
}
498498
const declId = b.relationships?.appEncryptionDeclaration?.data?.id || null;
499499
const declAttrs = declId ? includedDeclarations.get(declId) : null;
500+
// Derive compliance: if usesNonExemptEncryption is explicitly false, compliance is resolved
501+
let complianceState = declAttrs?.appEncryptionDeclarationState ?? null;
502+
if (!complianceState && attrs.usesNonExemptEncryption === false) {
503+
complianceState = "VALID";
504+
}
500505
return {
501506
id: b.id,
502507
version: attrs.version,
@@ -506,8 +511,8 @@ router.get("/:appId/builds", async (req, res) => {
506511
buildAudienceType: attrs.buildAudienceType,
507512
iconUrl,
508513
encryptionDeclarationId: declId,
509-
usesNonExemptEncryption: declAttrs?.usesNonExemptEncryption ?? null,
510-
complianceState: declAttrs?.appEncryptionDeclarationState ?? null,
514+
usesNonExemptEncryption: attrs.usesNonExemptEncryption ?? null,
515+
complianceState,
511516
};
512517
}).sort((a, b) => new Date(b.uploadedDate) - new Date(a.uploadedDate));
513518

@@ -533,7 +538,7 @@ router.get("/:appId/versions/:versionId/build", async (req, res) => {
533538
try {
534539
const data = await ascFetch(
535540
account,
536-
`/v1/appStoreVersions/${versionId}/build?fields[builds]=version,processingState,uploadedDate,minOsVersion`
541+
`/v1/appStoreVersions/${versionId}/build?fields[builds]=version,processingState,uploadedDate,minOsVersion,usesNonExemptEncryption`
537542
);
538543

539544
const build = data.data
@@ -543,6 +548,7 @@ router.get("/:appId/versions/:versionId/build", async (req, res) => {
543548
processingState: data.data.attributes.processingState,
544549
uploadedDate: data.data.attributes.uploadedDate,
545550
minOsVersion: data.data.attributes.minOsVersion,
551+
usesNonExemptEncryption: data.data.attributes.usesNonExemptEncryption ?? null,
546552
}
547553
: null;
548554

@@ -609,7 +615,7 @@ router.get("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) =>
609615
try {
610616
const data = await ascFetch(
611617
account,
612-
`/v1/builds/${buildId}/appEncryptionDeclaration?fields[appEncryptionDeclarations]=usesNonExemptEncryption,appEncryptionDeclarationState,containsProprietaryCryptography,containsThirdPartyCryptography,availableOnFrenchStore,codeValue,platform`
618+
`/v1/builds/${buildId}/appEncryptionDeclaration?fields[appEncryptionDeclarations]=appEncryptionDeclarationState,containsProprietaryCryptography,containsThirdPartyCryptography,availableOnFrenchStore,codeValue,platform`
613619
);
614620

615621
const decl = data.data
@@ -630,7 +636,7 @@ router.get("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) =>
630636

631637
router.patch("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) => {
632638
const { appId, buildId } = req.params;
633-
const { accountId, usesNonExemptEncryption, containsProprietaryCryptography, containsThirdPartyCryptography } = req.body;
639+
const { accountId, containsProprietaryCryptography, containsThirdPartyCryptography } = req.body;
634640

635641
if (!accountId) {
636642
return res.status(400).json({ error: "accountId is required" });
@@ -643,36 +649,61 @@ router.patch("/:appId/builds/:buildId/encryptionDeclaration", async (req, res) =
643649
}
644650

645651
try {
646-
// Fetch the existing declaration ID
647-
const declData = await ascFetch(
648-
account,
649-
`/v1/builds/${buildId}/appEncryptionDeclaration?fields[appEncryptionDeclarations]=appEncryptionDeclarationState`
650-
);
651-
652-
if (!declData.data) {
653-
return res.status(404).json({ error: "No encryption declaration found for this build" });
654-
}
655-
656-
const declarationId = declData.data.id;
657-
const attributes = { usesNonExemptEncryption };
658-
if (usesNonExemptEncryption) {
659-
if (containsProprietaryCryptography !== undefined) attributes.containsProprietaryCryptography = containsProprietaryCryptography;
660-
if (containsThirdPartyCryptography !== undefined) attributes.containsThirdPartyCryptography = containsThirdPartyCryptography;
661-
}
652+
const usesEncryption = containsProprietaryCryptography || containsThirdPartyCryptography;
662653

663-
await ascFetch(account, `/v1/appEncryptionDeclarations/${declarationId}`, {
664-
method: "PATCH",
665-
body: {
666-
data: {
667-
type: "appEncryptionDeclarations",
668-
id: declarationId,
669-
attributes,
654+
if (!usesEncryption) {
655+
// "No encryption" — set usesNonExemptEncryption=false on the build directly
656+
await ascFetch(account, `/v1/builds/${buildId}`, {
657+
method: "PATCH",
658+
body: {
659+
data: {
660+
type: "builds",
661+
id: buildId,
662+
attributes: { usesNonExemptEncryption: false },
663+
},
670664
},
671-
},
672-
});
665+
});
666+
} else {
667+
// "Yes encryption" — create a declaration and link it to the build
668+
const declAttrs = {
669+
containsProprietaryCryptography,
670+
containsThirdPartyCryptography,
671+
availableOnFrenchStore: false,
672+
appDescription: "N/A",
673+
};
674+
const created = await ascFetch(account, `/v1/appEncryptionDeclarations`, {
675+
method: "POST",
676+
body: {
677+
data: {
678+
type: "appEncryptionDeclarations",
679+
attributes: declAttrs,
680+
relationships: {
681+
app: { data: { type: "apps", id: appId } },
682+
},
683+
},
684+
},
685+
});
686+
// Link declaration to build via PATCH on the build
687+
await ascFetch(account, `/v1/builds/${buildId}`, {
688+
method: "PATCH",
689+
body: {
690+
data: {
691+
type: "builds",
692+
id: buildId,
693+
attributes: { usesNonExemptEncryption: true },
694+
relationships: {
695+
appEncryptionDeclaration: {
696+
data: { type: "appEncryptionDeclarations", id: created.data.id },
697+
},
698+
},
699+
},
700+
},
701+
});
702+
}
673703

674704
apiCache.deleteByPrefix(`apps:build-encryption:${buildId}:`);
675705
apiCache.deleteByPrefix(`apps:builds:${appId}:`);
706+
apiCache.deleteByPrefix(`apps:version-build:`);
676707

677708
res.json({ success: true });
678709
} catch (err) {
@@ -959,6 +990,180 @@ router.get("/:appId/review-submissions", async (req, res) => {
959990
}
960991
});
961992

993+
// ── Review Submission Detail ──────────────────────────────────────────────
994+
995+
const ITEM_STATE_DISPLAY = {
996+
READY_FOR_REVIEW: "Ready for Review",
997+
ACCEPTED: "Accepted",
998+
APPROVED: "Approved",
999+
REJECTED: "Rejected",
1000+
REMOVED: "Removed",
1001+
};
1002+
1003+
function parseReviewSubmissionDetail(data) {
1004+
const includedMap = new Map();
1005+
if (data.included) {
1006+
for (const inc of data.included) {
1007+
includedMap.set(`${inc.type}:${inc.id}`, inc);
1008+
}
1009+
}
1010+
1011+
const submission = data.data;
1012+
const attrs = submission.attributes;
1013+
1014+
// Resolve version string
1015+
let versions = null;
1016+
const versionRef = submission.relationships?.appStoreVersionForReview?.data;
1017+
if (versionRef) {
1018+
const ver = includedMap.get(`${versionRef.type}:${versionRef.id}`);
1019+
if (ver) {
1020+
const platform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform;
1021+
versions = `${platform} ${ver.attributes.versionString}`;
1022+
}
1023+
}
1024+
1025+
// Resolve actors
1026+
function resolveActor(relationshipName) {
1027+
const ref = submission.relationships?.[relationshipName]?.data;
1028+
if (!ref) return null;
1029+
const actor = includedMap.get(`${ref.type}:${ref.id}`);
1030+
if (!actor) return null;
1031+
return [actor.attributes.userFirstName, actor.attributes.userLastName].filter(Boolean).join(" ");
1032+
}
1033+
1034+
// Resolve items
1035+
const itemRefs = submission.relationships?.items?.data || [];
1036+
const items = itemRefs.map((ref) => {
1037+
const item = includedMap.get(`${ref.type}:${ref.id}`);
1038+
if (!item) return { id: ref.id, state: "UNKNOWN", displayState: "Unknown", type: "unknown" };
1039+
1040+
const itemState = item.attributes.state;
1041+
const itemType = ["appStoreVersion", "appCustomProductPage", "appStoreVersionExperiment", "appEvent"]
1042+
.find((rel) => item.relationships?.[rel]?.data) || "appStoreVersion";
1043+
1044+
let versionString = null;
1045+
let itemPlatform = null;
1046+
let appStoreState = null;
1047+
const itemVersionRef = item.relationships?.appStoreVersion?.data;
1048+
if (itemVersionRef) {
1049+
const ver = includedMap.get(`${itemVersionRef.type}:${itemVersionRef.id}`);
1050+
if (ver) {
1051+
versionString = ver.attributes.versionString;
1052+
itemPlatform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform;
1053+
appStoreState = ver.attributes.appStoreState;
1054+
}
1055+
}
1056+
1057+
// If no version from items, fall back to the submission-level version
1058+
if (!versionString && versionRef) {
1059+
const ver = includedMap.get(`${versionRef.type}:${versionRef.id}`);
1060+
if (ver) {
1061+
versionString = ver.attributes.versionString;
1062+
itemPlatform = ver.attributes.platform === "IOS" ? "iOS" : ver.attributes.platform === "MAC_OS" ? "macOS" : ver.attributes.platform;
1063+
appStoreState = ver.attributes.appStoreState;
1064+
}
1065+
}
1066+
1067+
return {
1068+
id: item.id,
1069+
state: itemState,
1070+
displayState: ITEM_STATE_DISPLAY[itemState] || itemState,
1071+
type: itemType,
1072+
versionString,
1073+
platform: itemPlatform,
1074+
appStoreState,
1075+
};
1076+
});
1077+
1078+
// If no version resolved yet, try from items
1079+
if (!versions && items.length > 0) {
1080+
const versionStrings = new Set(items.filter((i) => i.versionString && i.platform).map((i) => `${i.platform} ${i.versionString}`));
1081+
if (versionStrings.size > 1) versions = "Multiple Versions";
1082+
else if (versionStrings.size === 1) versions = [...versionStrings][0];
1083+
}
1084+
1085+
const displayStatus = REVIEW_STATE_DISPLAY[attrs.state] || attrs.state;
1086+
const platform = attrs.platform === "IOS" ? "iOS" : attrs.platform === "MAC_OS" ? "macOS" : attrs.platform;
1087+
1088+
return {
1089+
id: submission.id,
1090+
state: attrs.state,
1091+
displayStatus,
1092+
platform,
1093+
submittedDate: attrs.submittedDate,
1094+
versions: versions || "Unknown",
1095+
submittedBy: resolveActor("submittedByActor"),
1096+
lastUpdatedBy: resolveActor("lastUpdatedByActor"),
1097+
items,
1098+
};
1099+
}
1100+
1101+
router.get("/:appId/review-submissions/:submissionId", async (req, res) => {
1102+
const { appId, submissionId } = req.params;
1103+
const { accountId } = req.query;
1104+
1105+
const cacheKey = `apps:review-submission-detail:${submissionId}:${accountId || "default"}`;
1106+
const cached = apiCache.get(cacheKey);
1107+
if (cached) return res.json(cached);
1108+
1109+
const accounts = getAccounts();
1110+
const account = accounts.find((a) => a.id === accountId) || accounts[0];
1111+
1112+
try {
1113+
const submissionUrl = `/v1/reviewSubmissions/${submissionId}`
1114+
+ "?include=items,appStoreVersionForReview,submittedByActor,lastUpdatedByActor"
1115+
+ "&fields[reviewSubmissions]=submittedDate,state,platform"
1116+
+ "&fields[reviewSubmissionItems]=state,appStoreVersion"
1117+
+ "&fields[appStoreVersions]=versionString,platform,appStoreState"
1118+
+ "&fields[actors]=userFirstName,userLastName";
1119+
1120+
const itemsUrl = `/v1/reviewSubmissions/${submissionId}/items`
1121+
+ "?include=appStoreVersion"
1122+
+ "&fields[reviewSubmissionItems]=state,appStoreVersion"
1123+
+ "&fields[appStoreVersions]=versionString,platform,appStoreState";
1124+
1125+
const [submissionData, itemsData] = await Promise.all([
1126+
ascFetch(account, submissionUrl),
1127+
ascFetch(account, itemsUrl),
1128+
]);
1129+
1130+
// Replace item objects with richer ones from items endpoint (they carry appStoreVersion relationship)
1131+
if (!submissionData.included) submissionData.included = [];
1132+
if (itemsData.data) {
1133+
for (const item of itemsData.data) {
1134+
const idx = submissionData.included.findIndex((e) => e.type === item.type && e.id === item.id);
1135+
if (idx >= 0) submissionData.included[idx] = item;
1136+
else submissionData.included.push(item);
1137+
}
1138+
}
1139+
// Merge included version objects from items endpoint
1140+
if (itemsData.included) {
1141+
for (const inc of itemsData.included) {
1142+
const exists = submissionData.included.some((e) => e.type === inc.type && e.id === inc.id);
1143+
if (!exists) submissionData.included.push(inc);
1144+
}
1145+
}
1146+
1147+
// DEBUG: log raw API responses to understand structure
1148+
console.log("=== SUBMISSION DATA ===");
1149+
console.log("data.relationships:", JSON.stringify(submissionData.data?.relationships, null, 2));
1150+
console.log("included types:", submissionData.included?.map(i => `${i.type}:${i.id}`));
1151+
console.log("=== ITEMS DATA ===");
1152+
console.log("itemsData.data:", JSON.stringify(itemsData.data, null, 2));
1153+
console.log("itemsData.included:", JSON.stringify(itemsData.included, null, 2));
1154+
console.log("=== MERGED INCLUDED ===");
1155+
console.log("all included:", submissionData.included?.map(i => `${i.type}:${i.id} rels=${Object.keys(i.relationships || {}).join(",")}`));
1156+
1157+
const result = parseReviewSubmissionDetail(submissionData);
1158+
1159+
apiCache.set(cacheKey, result);
1160+
res.json(result);
1161+
} catch (err) {
1162+
console.error(`Failed to fetch review submission detail ${submissionId}:`, err.message);
1163+
res.status(502).json({ error: err.message });
1164+
}
1165+
});
1166+
9621167
router.post("/:appId/versions/:versionId/submit", async (req, res) => {
9631168
const { appId, versionId } = req.params;
9641169
const { accountId, platform } = req.body;

src/components/BuildComplianceModal.jsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -61,12 +61,12 @@ export default function BuildComplianceModal({ build, appId, accountId, onClose,
6161
setSaving(true);
6262
setError(null);
6363
try {
64-
const data = { accountId, usesNonExemptEncryption: usesEncryption };
65-
if (usesEncryption && selectedAlgorithm) {
66-
const algo = ALGORITHM_OPTIONS.find((a) => a.id === selectedAlgorithm);
67-
data.containsProprietaryCryptography = algo.proprietary;
68-
data.containsThirdPartyCryptography = algo.thirdParty;
69-
}
64+
const algo = ALGORITHM_OPTIONS.find((a) => a.id === selectedAlgorithm);
65+
const data = {
66+
accountId,
67+
containsProprietaryCryptography: algo?.proprietary ?? false,
68+
containsThirdPartyCryptography: algo?.thirdParty ?? false,
69+
};
7070
await updateBuildEncryptionDeclaration(appId, build.id, data);
7171
onSuccess();
7272
} catch (err) {
@@ -84,7 +84,8 @@ export default function BuildComplianceModal({ build, appId, accountId, onClose,
8484
setError(null);
8585
updateBuildEncryptionDeclaration(appId, build.id, {
8686
accountId,
87-
usesNonExemptEncryption: false,
87+
containsProprietaryCryptography: false,
88+
containsThirdPartyCryptography: false,
8889
})
8990
.then(() => onSuccess())
9091
.catch((err) => {

0 commit comments

Comments
 (0)