// App Review information on the editable version: contact, demo account, notes. Idempotent. // storebound apple review-details [--contact-from ] [--dry] // The contact (name, phone, email) is personal data, so it is never in the config: it comes from // .storebound/credentials.json "reviewContact", or is copied from another app of the same team with --contact-from. // Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or // REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus // reviewAccess.instructions and accounts.inApp (lib/review-details.ts). import { refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { findReviewContact, noContactMessage, reviewDemoAccount } from '../lib/credentials'; import { reviewDetailPlan } from '../lib/review-details'; import { done, log } from '../lib/log'; import { createOnce } from '../lib/upsert'; import { ascClient } from './asc'; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const version = await c.editableVersion(); if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)'); let contact: Record | null = null; const from = ctx.args.get('contact-from'); if (from) { for (const v of await c.all(`/v1/apps/${from}/appStoreVersions?limit=10`)) { const a = (await c.maybe(`/v1/appStoreVersions/${v.id}/appStoreReviewDetail`))?.data?.attributes; if (a?.contactEmail) { contact = { contactFirstName: a.contactFirstName, contactLastName: a.contactLastName, contactPhone: a.contactPhone, contactEmail: a.contactEmail }; break; } } if (!contact) throw new Error(`app ${from} has no review contact on its recent versions`); } let checked: string[] = []; if (!from) { const found = findReviewContact(root); checked = found.checked; const rc = found.contact; if (rc) contact = { contactFirstName: rc.firstName, contactLastName: rc.lastName, contactPhone: rc.phone, contactEmail: rc.email }; } if (!contact) throw new Error(noContactMessage(root, checked)); const { attributes, summary } = reviewDetailPlan(config, contact, reviewDemoAccount(root)); if (ctx.args.has('dry')) { log('apple', `would set review details (contact ${from ? 'copied from ' + from : 'from credentials'}, ${summary})`); return; } const read = async () => (await c.maybe(`/v1/appStoreVersions/${version.id}/appStoreReviewDetail`))?.data ?? null; const cur = await read(); // one review detail per version: only changed fields are sent; a new one is created once (a 409 finds it) const changed = cur ? Object.fromEntries(Object.entries(attributes).filter(([k, v]) => (cur.attributes?.[k] ?? null) !== (v ?? null))) : attributes; if (cur && !Object.keys(changed).length) { done('apple', `review details on ${version.attributes.versionString} up to date (${summary})`); return; } if (cur) await c.asc('PATCH', `/v1/appStoreReviewDetails/${cur.id}`, { data: { type: 'appStoreReviewDetails', id: cur.id, attributes: changed } }); else { const r = await createOnce({ what: 'App Review details', tag: 'apple', unique: true, find: read, create: async () => (await c.asc('POST', '/v1/appStoreReviewDetails', { data: { type: 'appStoreReviewDetails', attributes, relationships: { appStoreVersion: { data: { type: 'appStoreVersions', id: version.id } } } } })).data }); if (!r.created) await c.asc('PATCH', `/v1/appStoreReviewDetails/${r.value.id}`, { data: { type: 'appStoreReviewDetails', id: r.value.id, attributes } }); } done('apple', `review details set on ${version.attributes.versionString} (${summary})`); }