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
62 changes: 50 additions & 12 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
{
"version": "0.2.0",
"configurations": [
{
// Wipes `dist/` and `node_modules/.vite`, then ALWAYS
// rebuilds the SPA with `vite build --mode development`
// so Hono's static fallback at port 3000 serves a bundle
// baked against `.env.local` (dev Convex URL) — never
// `.env.production`. Without `--mode development`,
// `bun run build` defaults to production mode, loads
// `.env.production` AFTER `.env.local`, and the prod URL
// wins; that's why localhost:3000 sign-ins were bouncing
// through the prod GitHub OAuth App even after wiping
// dist (Hyo Dev / martie triage).
"name": "🧰 Kit: Dev (Vite + Hono + Convex)",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"npx --yes kill-port 3000 5173 || true && rm -rf dist node_modules/.vite && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && bunx vite build --mode development && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)"
],
"cwd": "${workspaceFolder}/packages/kit",
"console": "integratedTerminal"
},
{
"type": "node-terminal",
"request": "launch",
Expand Down Expand Up @@ -96,6 +118,34 @@
"cwd": "${workspaceFolder}/libraries/flutter_inapp_purchase/example",
"console": "integratedTerminal"
},
{
// Targets iPad (UDID 00008130-001929642642001C). To switch devices,
// run `xcrun xctrace list devices` and replace the UDID below.
// -tl:off + -v:n + MlaunchVerbosity=4 + MlaunchExtraArgs surface
// per-stage install progress (AFC byte transfer, codesign, launch).
// The leading `cd` re-asserts cwd because `bash -lc` reloads the
// login profile, which can move the shell out of cwd before
// dotnet runs.
"name": "🟣 MAUI IAP: iOS",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-ios -p:RuntimeIdentifier=ios-arm64 -p:_DeviceName=:v2:udid=00008130-001929642642001C -tl:off -v:n -p:MlaunchVerbosity=4 -p:MlaunchExtraArgs=\"-v -v -v\""],
"cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example",
"console": "integratedTerminal"
},
{
// -tl:off + -v:n — same fix as iOS. The terminal logger collapses
// every MSBuild target into a rolling counter; turning it off makes
// _CompileToDalvik / _InstallApk / etc. stream line-by-line.
"name": "🟣 MAUI IAP: Android",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": ["-lc", "cd \"${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example\" && (adb uninstall dev.hyo.openiap.maui.example || true) && dotnet build OpenIap.Maui.Example.csproj -t:Run -f net9.0-android -tl:off -v:n"],
"cwd": "${workspaceFolder}/libraries/maui-iap/example/OpenIap.Maui.Example",
"console": "integratedTerminal"
},
{
"name": "🎮 Godot IAP: Open in Editor",
"type": "node",
Expand All @@ -122,18 +172,6 @@
"runtimeArgs": ["-lc", "adb uninstall dev.hyo.martie || true && ./gradlew :example:composeApp:installDebug && adb shell am start -n dev.hyo.martie/.MainActivity"],
"cwd": "${workspaceFolder}/libraries/kmp-iap",
"console": "integratedTerminal"
},
{
"name": "🧰 Kit: Dev (Vite + Hono + Convex)",
"type": "node",
"request": "launch",
"runtimeExecutable": "bash",
"runtimeArgs": [
"-lc",
"npx --yes kill-port 3000 5173 || true && ([ -d ../../node_modules ] || (echo '📦 Installing workspace dependencies...' && cd ../.. && bun install)) && trap 'kill 0' INT TERM EXIT && (bun run dev:convex & bun run dev:server & bun run dev; wait)"
],
"cwd": "${workspaceFolder}/packages/kit",
"console": "integratedTerminal"
}
]
}
37 changes: 30 additions & 7 deletions packages/kit/convex/products/asc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,37 @@ async function resolveAscCredentials(
project: Awaited<ReturnType<typeof getProjectByApiKey>>,
options: { detailedErrors?: boolean } = {},
): Promise<AscCredentials> {
// Resolve as a *pair* — never mix the new ASC Issuer ID with the
// legacy Server API Key ID (or vice versa). If only one of the
// new fields is populated the operator is mid-migration; in that
// case fall back to the legacy pair entirely so we don't sign a
// request with mismatched identifiers Apple will reject as 401.
const useAsc = project.iosAscIssuerId && project.iosAscKeyId;
// Apple uses ONE Issuer ID per team across both API gateways
// (App Store Server API + App Store Connect API), so the
// Settings UI deliberately exposes a single shared Issuer ID
// input that writes to `iosAppStoreIssuerId` — `iosAscIssuerId`
// is never populated through the UI and only exists for
// backwards-compat with the brief window when both were
// separate inputs.
//
// The Key IDs are NOT shared: `iosAppStoreKeyId` is the In-App
// Purchase key (receipt verification) and `iosAscKeyId` is the
// App Store Connect API Team / Individual key (catalog
// management). They authenticate against different gateways and
// every Apple-issued key has a unique 10-char id.
//
// Pair-resolution rule: if `iosAscKeyId` is set, sign with the
// ASC pair (issuer falls back to the shared `iosAppStoreIssuerId`
// when `iosAscIssuerId` is missing). If `iosAscKeyId` is missing,
// fall back to the legacy single-slot Server API pair so projects
// mid-migration still work — `call()` surfaces a wrong-kind 401
// hint when Apple rejects a Server-API key on an ASC endpoint.
//
// Earlier the gate required BOTH `iosAscIssuerId` AND
// `iosAscKeyId` to be set, which never happened in production
// (UI doesn't expose the Issuer field). The fallback then sent
// the JWT with `kid: iosAppStoreKeyId` (Server API key id) but
// signed with the ASC private key, and Apple rejected every
// request with a 401 across all production deployments
// (LukasB-DEV's report on PR #127).
const useAsc = !!project.iosAscKeyId;
const issuerId = useAsc
? project.iosAscIssuerId
? (project.iosAscIssuerId ?? project.iosAppStoreIssuerId)
: project.iosAppStoreIssuerId;
const keyId = useAsc ? project.iosAscKeyId : project.iosAppStoreKeyId;
if (!issuerId || !keyId) {
Expand Down
147 changes: 119 additions & 28 deletions packages/kit/src/pages/auth/organization/project/products.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,14 +54,38 @@ export default function ProjectProducts() {

// Sync state now lives in `productSyncJobs` and is read reactively
// via `getActiveSyncJob` per platform — the worker writes progress
// back to the row, so the dashboard re-renders without polling. The
// local `lastShownJobId*` refs gate the completion toast so a
// succeeded job only toasts once even if the row updates again
// (e.g. on dismiss).
const lastShownJobIdRef = useRef<Record<"IOS" | "Android", string | null>>({
// back to the row, so the dashboard re-renders without polling.
//
// Toast policy: only fire a completion toast for jobs the operator
// *actively triggered in this mounted session*. We track the
// previous status per platform; a toast fires only on the
// transition `running/queued → succeeded/failed`, never on the
// first observed status. That way revisiting the page (where the
// first observation is already terminal) shows the result banner
// but does NOT pop a stale toast for a sync the operator didn't
// just run.
// Pull the field types straight off `SyncJob` (= `Doc<"productSyncJobs">`)
// so the snapshot stays in lockstep with the schema — adding a new
// status literal in `convex/schema.ts` automatically widens the
// local type instead of silently drifting (Gemini SSOT review on
// PR #128).
type JobStatusSnapshot = {
jobId: SyncJob["_id"];
status: SyncJob["status"];
};
Comment thread
hyochan marked this conversation as resolved.
const prevJobStatusRef = useRef<
Record<"IOS" | "Android", JobStatusSnapshot | null>
>({
IOS: null,
Android: null,
});
// Job ids the operator triggered FROM THIS MOUNT (Sync / Dry-run /
// Reset clicks). Result banner + completion toast both gate on
// this so a stale terminal job from a previous session — left
// over after a code edit / HMR reload / page revisit — doesn't
// re-surface as if a sync had just happened. Reset on remount so
// the gate is automatic and never sticky.
const sessionTriggeredJobIdsRef = useRef<Set<string>>(new Set());
// The draft form holds every field the push-sync flow consumes.
// Optional fields are stored as empty strings here and converted to
// `undefined` on submit so an unfilled price doesn't end up
Expand All @@ -86,25 +110,41 @@ export default function ProjectProducts() {
};
}, [products]);

// Show success/failure toast exactly once when a job transitions to
// a terminal state. The lastShownJobIdRef guard prevents the toast
// from re-firing on subsequent reactive updates of the same row.
// Toast on the running → terminal transition only.
//
// Earlier versions used a "shown jobIds" set, which fired on
// every fresh mount because the ref reset to empty — landing on
// the page with a pre-existing terminal job re-toasted it every
// time. The transition rule means: the very first observation
// of a job (no matter its status) just records state without
// toasting; subsequent observations fire only when the status
// crossed from non-terminal to terminal.
useEffect(() => {
for (const platform of ["IOS", "Android"] as const) {
const job = platform === "IOS" ? iosJob : androidJob;
if (!job) continue;
const prev = prevJobStatusRef.current[platform];
const terminal = job.status === "succeeded" || job.status === "failed";
// Update the snapshot before deciding whether to toast — so
// even if we don't toast (initial observation, dismissed, or
// unchanged status) we still track the latest state.
prevJobStatusRef.current[platform] = {
jobId: job._id,
status: job.status,
};
if (!terminal) continue;
// Once the operator has dismissed a terminal job, the row's
// `progress.phase` flips to "dismissed". The result banner
// already gates on this; the toast effect needs the same
// gate or it re-fires the success/failure toast on every
// subsequent page reload (because `lastShownJobIdRef` is
// in-memory and resets) until the pruner deletes the row
// (CodeRabbit review on PR #127).
if (job.progress.phase === "dismissed") continue;
if (lastShownJobIdRef.current[platform] === job._id) continue;
lastShownJobIdRef.current[platform] = job._id;
// Initial observation (no prev snapshot) OR a new jobId we've
// never seen → don't toast. We only toast for the same jobId
// when the previous render saw it in a non-terminal state.
if (!prev || prev.jobId !== job._id) continue;
if (prev.status !== "queued" && prev.status !== "running") continue;
// Belt-and-braces: only toast for jobs the operator triggered
// FROM THIS MOUNT. Without this gate a sync started in another
// tab that completes while this tab is open would also pop a
// toast here, which the operator would read as "did I just
// run that?".
if (!sessionTriggeredJobIdsRef.current.has(job._id)) continue;
const label = platform === "IOS" ? "App Store Connect" : "Play Console";
const result = job.result;
if (job.status === "succeeded" && result) {
Expand Down Expand Up @@ -206,12 +246,13 @@ export default function ProjectProducts() {
const label = platform === "IOS" ? "App Store Connect" : "Play Console";
const dryRun = options?.dryRun === true;
try {
const { deduped } = await enqueueSync({
const { jobId, deduped } = await enqueueSync({
apiKey: project.apiKey,
platform,
direction: "both",
...(dryRun ? { dryRun: true } : {}),
});
sessionTriggeredJobIdsRef.current.add(jobId);
if (deduped) {
toast.message(`${label} sync already running`, { duration: 4_000 });
} else {
Expand All @@ -233,11 +274,12 @@ export default function ProjectProducts() {
if (isActive) return;
const label = platform === "IOS" ? "App Store Connect" : "Play Console";
try {
const { deduped } = await enqueueSync({
const { jobId, deduped } = await enqueueSync({
apiKey: project.apiKey,
platform,
direction: "purge-local",
});
sessionTriggeredJobIdsRef.current.add(jobId);
if (deduped) {
toast.message(`${label} reset already running`, { duration: 4_000 });
} else {
Expand Down Expand Up @@ -466,6 +508,9 @@ export default function ProjectProducts() {
platform="IOS"
rows={grouped.ios}
job={iosJob ?? null}
triggeredInSession={
!!iosJob?._id && sessionTriggeredJobIdsRef.current.has(iosJob._id)
}
onSync={() => {
void onSync("IOS");
}}
Expand All @@ -486,6 +531,10 @@ export default function ProjectProducts() {
platform="Android"
rows={grouped.android}
job={androidJob ?? null}
triggeredInSession={
!!androidJob?._id &&
sessionTriggeredJobIdsRef.current.has(androidJob._id)
}
onSync={() => {
void onSync("Android");
}}
Expand Down Expand Up @@ -622,15 +671,18 @@ function formatIsoDuration(iso: string): string {

// Reorder a flat product list so subscriptions sharing the same ASC
// `subscriptionGroupName` are visually clustered under a single
// "Subscription Group · {name}" header row. One-time products (no
// group) and rows missing group metadata pass through unchanged at
// the bottom — we don't want to invent a synthetic group label for
// non-subscriptions or for legacy rows synced before group capture
// landed. Original ordering is preserved within each cluster.
// "Subscription Group · {name}" header row. One-time products
// (Consumable / NonConsumable) and any row missing group metadata
// fall into an "Other products" cluster rendered after the
// subscription groups so the section breaks are explicit — without
// the second header, consumables visually inherited the previous
// "Subscription Group" header and looked like part of it.
// Original ordering is preserved within each cluster.
function groupRowsByHierarchy(
rows: Array<ProductRow>,
): Array<
| { kind: "groupHeader"; id: string; name: string }
| { kind: "otherHeader"; id: string }
| { kind: "row"; row: ProductRow }
> {
const buckets = new Map<string, Array<ProductRow>>();
Expand All @@ -650,6 +702,7 @@ function groupRowsByHierarchy(
}
const out: Array<
| { kind: "groupHeader"; id: string; name: string }
| { kind: "otherHeader"; id: string }
| { kind: "row"; row: ProductRow }
> = [];
for (const name of groupOrder) {
Expand All @@ -658,6 +711,14 @@ function groupRowsByHierarchy(
out.push({ kind: "row", row });
}
}
// Only emit the "Other products" delimiter when at least one
// subscription group is also rendering — when there are no
// groups, the table is just a flat list and the extra header
// is noise. With at least one group above, the explicit
// delimiter is what visually closes the group section.
if (groupOrder.length > 0 && ungrouped.length > 0) {
out.push({ kind: "otherHeader", id: "other-products" });
}
for (const row of ungrouped) {
out.push({ kind: "row", row });
}
Expand Down Expand Up @@ -868,6 +929,7 @@ function ProductGroup({
platform,
rows,
job,
triggeredInSession,
onSync,
onDryRun,
onPurge,
Expand All @@ -877,6 +939,7 @@ function ProductGroup({
platform: "IOS" | "Android";
rows: Array<ProductRow>;
job: SyncJob | null;
triggeredInSession: boolean;
onSync: () => void;
onDryRun?: () => void;
onPurge: () => void;
Expand All @@ -887,7 +950,11 @@ function ProductGroup({
const isActive = job?.status === "queued" || job?.status === "running";
const isTerminal = job?.status === "succeeded" || job?.status === "failed";
const dismissed = job?.progress.phase === "dismissed";
const showResult = isTerminal && !dismissed;
// Result banner only surfaces for jobs the operator triggered
// FROM THIS MOUNT — stale terminal jobs from prior sessions
// (HMR reload, page revisit) stay hidden so the operator can't
// mistake them for a sync that just ran.
const showResult = isTerminal && !dismissed && triggeredInSession;
const [purgeOpen, setPurgeOpen] = useState(false);
return (
<div className="border border-border rounded-lg bg-card overflow-hidden">
Expand Down Expand Up @@ -1015,13 +1082,37 @@ function ProductGroup({
entry.kind === "groupHeader" ? (
<tr
key={`group:${entry.id}`}
className="border-t border-border/50 bg-muted/20"
className="border-t-2 border-l-4 border-blue-500/60 border-t-blue-500/30 bg-blue-500/10"
>
<td
colSpan={6}
className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider"
>
<span className="inline-flex items-center gap-2">
<span className="text-blue-700 dark:text-blue-300">
Subscription Group
</span>
<span className="text-blue-700/40 dark:text-blue-300/40">
·
</span>
<span className="font-mono normal-case tracking-normal text-foreground">
{entry.name}
</span>
</span>
</td>
</tr>
) : entry.kind === "otherHeader" ? (
<tr
key={`other:${entry.id}`}
className="border-t-2 border-l-4 border-amber-500/50 border-t-amber-500/30 bg-amber-500/10"
>
<td
colSpan={6}
className="px-4 py-1.5 text-xs uppercase tracking-wide text-muted-foreground"
className="px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider"
>
Subscription Group · {entry.name}
<span className="text-amber-700 dark:text-amber-300">
Other products
</span>
</td>
</tr>
) : (
Expand Down