Skip to content

Commit dcaef9d

Browse files
Merge pull request #8 from shadowdevcode/codex/final-ui-release-candidate
fix: degrade gracefully when unknown queue read fails
2 parents b6467ff + 6966b50 commit dcaef9d

File tree

5 files changed

+205
-57
lines changed

5 files changed

+205
-57
lines changed

src/App.tsx

Lines changed: 75 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
collection,
66
doc,
77
getDoc,
8+
getDocs,
89
onSnapshot,
910
orderBy,
1011
query,
@@ -31,10 +32,12 @@ import { getAppCopy } from './i18n/copy';
3132
import firebaseConfig from '../firebase-applet-config.json';
3233
import {
3334
buildUnknownQueueTargetFingerprint,
35+
classifyUnknownQueueLoadFailure,
3436
classifyHouseholdMembershipProbe,
35-
getUnknownQueueLoadErrorMessage,
3637
HouseholdMembershipProbeResult,
3738
isFirestoreFailedPreconditionError,
39+
isFirestorePermissionDeniedError,
40+
type UnknownQueueReadProbeResult,
3841
sortUnknownIngredientQueueItemsByCreatedAt,
3942
toFirestoreListenerErrorInfo,
4043
} from './utils/unknownQueue';
@@ -80,6 +83,7 @@ export default function App() {
8083
const [inviteEmail, setInviteEmail] = useState('');
8184
const [isInviting, setIsInviting] = useState(false);
8285
const [uiFeedback, setUiFeedback] = useState<UiFeedback | null>(null);
86+
const [unknownQueueWarning, setUnknownQueueWarning] = useState<string | null>(null);
8387
const isOwner = role === 'owner';
8488
const ownerLanguage: UiLanguage = householdData?.ownerLanguage ?? 'en';
8589
const cookLanguage: UiLanguage = householdData?.cookLanguage ?? 'hi';
@@ -99,6 +103,7 @@ export default function App() {
99103
setHouseholdId(null);
100104
setHouseholdData(null);
101105
setAccessRevoked(false);
106+
setUnknownQueueWarning(null);
102107
}
103108
});
104109

@@ -221,6 +226,7 @@ export default function App() {
221226

222227
const handleUnknownQueueLoaded = (items: UnknownIngredientQueueItem[]): void => {
223228
setUnknownIngredientQueue(items);
229+
setUnknownQueueWarning(null);
224230
hasLoadedUnknownQueue = true;
225231
markInitialViewReady();
226232
};
@@ -256,6 +262,70 @@ export default function App() {
256262
membershipProbeResult,
257263
});
258264

265+
const probeUnknownQueuePlainRead = async (): Promise<UnknownQueueReadProbeResult> => {
266+
try {
267+
await getDocs(collection(db, `households/${resolved.householdId}/unknownIngredientQueue`));
268+
return 'succeeded';
269+
} catch (probeError) {
270+
const parsedProbeError = toFirestoreListenerErrorInfo(probeError);
271+
if (isFirestorePermissionDeniedError(parsedProbeError)) {
272+
return 'permission-denied';
273+
}
274+
275+
console.error('unknown_queue_plain_read_probe_failed', {
276+
error: probeError,
277+
householdId: resolved.householdId,
278+
code: parsedProbeError.code,
279+
message: parsedProbeError.message,
280+
projectId: firebaseConfig.projectId,
281+
databaseId: firebaseConfig.firestoreDatabaseId,
282+
buildId: appBuildId,
283+
uid: user.uid,
284+
email: user.email ?? null,
285+
path: unknownQueuePath,
286+
targetFingerprint,
287+
membershipProbeResult,
288+
});
289+
return 'failed';
290+
}
291+
};
292+
293+
const applyUnknownQueueFailure = async (
294+
parsedError: ReturnType<typeof toFirestoreListenerErrorInfo>,
295+
logLabel: 'unknown_queue_snapshot_failed' | 'unknown_queue_snapshot_fallback_failed',
296+
error: unknown,
297+
): Promise<void> => {
298+
const plainReadProbeResult = isFirestorePermissionDeniedError(parsedError)
299+
? await probeUnknownQueuePlainRead()
300+
: 'not-run';
301+
const classification = classifyUnknownQueueLoadFailure({
302+
error: parsedError,
303+
membershipProbeResult,
304+
plainReadProbeResult,
305+
});
306+
307+
console.error(logLabel, {
308+
error,
309+
householdId: resolved.householdId,
310+
code: parsedError.code,
311+
message: parsedError.message,
312+
diagnosticKind: classification.diagnosticKind,
313+
plainReadProbeResult,
314+
projectId: firebaseConfig.projectId,
315+
databaseId: firebaseConfig.firestoreDatabaseId,
316+
buildId: appBuildId,
317+
uid: user.uid,
318+
email: user.email ?? null,
319+
path: unknownQueuePath,
320+
targetFingerprint,
321+
membershipProbeResult,
322+
});
323+
324+
setUnknownQueueWarning(appendBuildIdToDiagnosticMessage(classification.userMessage, appBuildId));
325+
hasLoadedUnknownQueue = true;
326+
markInitialViewReady();
327+
};
328+
259329
const subscribeUnknownQueueFallback = (): void => {
260330
if (unknownQueueFallbackUnsub !== null) {
261331
return;
@@ -272,29 +342,7 @@ export default function App() {
272342
},
273343
(error) => {
274344
const parsedError = toFirestoreListenerErrorInfo(error);
275-
console.error('unknown_queue_snapshot_fallback_failed', {
276-
error,
277-
householdId: resolved.householdId,
278-
code: parsedError.code,
279-
message: parsedError.message,
280-
projectId: firebaseConfig.projectId,
281-
databaseId: firebaseConfig.firestoreDatabaseId,
282-
buildId: appBuildId,
283-
uid: user.uid,
284-
email: user.email ?? null,
285-
path: unknownQueuePath,
286-
targetFingerprint,
287-
membershipProbeResult,
288-
});
289-
setUiFeedback({
290-
kind: 'error',
291-
message: appendBuildIdToDiagnosticMessage(
292-
getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult),
293-
appBuildId,
294-
),
295-
});
296-
hasLoadedUnknownQueue = true;
297-
markInitialViewReady();
345+
void applyUnknownQueueFailure(parsedError, 'unknown_queue_snapshot_fallback_failed', error);
298346
},
299347
);
300348
};
@@ -310,46 +358,17 @@ export default function App() {
310358
},
311359
(error) => {
312360
const parsedError = toFirestoreListenerErrorInfo(error);
313-
console.error('unknown_queue_snapshot_failed', {
314-
error,
315-
householdId: resolved.householdId,
316-
code: parsedError.code,
317-
message: parsedError.message,
318-
projectId: firebaseConfig.projectId,
319-
databaseId: firebaseConfig.firestoreDatabaseId,
320-
buildId: appBuildId,
321-
uid: user.uid,
322-
email: user.email ?? null,
323-
path: unknownQueuePath,
324-
targetFingerprint,
325-
membershipProbeResult,
326-
});
327-
328361
if (isFirestoreFailedPreconditionError(parsedError)) {
329362
if (unknownQueueUnsub !== null) {
330363
unknownQueueUnsub();
331364
unknownQueueUnsub = null;
332365
}
333-
setUiFeedback({
334-
kind: 'error',
335-
message: appendBuildIdToDiagnosticMessage(
336-
getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult),
337-
appBuildId,
338-
),
339-
});
366+
setUnknownQueueWarning(appendBuildIdToDiagnosticMessage('Review queue order is temporarily unavailable.', appBuildId));
340367
subscribeUnknownQueueFallback();
341368
return;
342369
}
343370

344-
setUiFeedback({
345-
kind: 'error',
346-
message: appendBuildIdToDiagnosticMessage(
347-
getUnknownQueueLoadErrorMessage(parsedError, membershipProbeResult),
348-
appBuildId,
349-
),
350-
});
351-
hasLoadedUnknownQueue = true;
352-
markInitialViewReady();
371+
void applyUnknownQueueFailure(parsedError, 'unknown_queue_snapshot_failed', error);
353372
},
354373
);
355374
} catch (error) {
@@ -799,6 +818,7 @@ export default function App() {
799818
onClearAnomaly={handleClearAnomaly}
800819
logs={logs}
801820
unknownIngredientQueue={unknownIngredientQueue}
821+
unknownQueueWarning={unknownQueueWarning}
802822
onPromoteUnknownIngredient={handlePromoteUnknownIngredient}
803823
onDismissUnknownIngredient={handleDismissUnknownIngredient}
804824
language={ownerLanguage}

src/components/OwnerView.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,13 @@ interface Props {
2828
onClearAnomaly: (id: string) => void;
2929
logs: PantryLog[];
3030
unknownIngredientQueue: UnknownIngredientQueueItem[];
31+
unknownQueueWarning: string | null;
3132
onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void;
3233
onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void;
3334
language: UiLanguage;
3435
}
3536

36-
export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) {
37+
export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, unknownQueueWarning, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) {
3738
const [activeTab, setActiveTab] = useState<OwnerTab>('meals');
3839
const tabRefs = useRef<Record<OwnerTab, HTMLButtonElement | null>>({
3940
meals: null,
@@ -109,6 +110,7 @@ export default function OwnerView({ meals, onUpdateMeal, inventory, onAddInvento
109110
onClearAnomaly={onClearAnomaly}
110111
logs={logs}
111112
unknownIngredientQueue={unknownIngredientQueue}
113+
unknownQueueWarning={unknownQueueWarning}
112114
onPromoteUnknownIngredient={onPromoteUnknownIngredient}
113115
onDismissUnknownIngredient={onDismissUnknownIngredient}
114116
language={language}

src/components/Pantry.tsx

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface Props {
2020
onClearAnomaly: (id: string) => void;
2121
logs: PantryLog[];
2222
unknownIngredientQueue: UnknownIngredientQueueItem[];
23+
unknownQueueWarning: string | null;
2324
onPromoteUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void;
2425
onDismissUnknownIngredient: (queueItem: UnknownIngredientQueueItem) => void;
2526
language: UiLanguage;
@@ -71,7 +72,7 @@ function getRoleLabel(language: UiLanguage, role: Role): string {
7172
return role === 'owner' ? 'Owner' : 'Cook';
7273
}
7374

74-
export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) {
75+
export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventory, onDeleteInventoryItem, onClearAnomaly, logs, unknownIngredientQueue, unknownQueueWarning, onPromoteUnknownIngredient, onDismissUnknownIngredient, language }: Props) {
7576
const [newItemName, setNewItemName] = useState<string>('');
7677
const [newItemCategory, setNewItemCategory] = useState<PantryCategoryKey>('spices');
7778
const [newItemQuantity, setNewItemQuantity] = useState<string>('');
@@ -108,6 +109,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
108109
queueTitle: 'अज्ञात सामग्री समीक्षा कतार',
109110
queueHelper: 'कुक की नई अनमैच सामग्री रिक्वेस्ट यहां आएगी। पेंट्री में प्रमोट करें या खारिज करें।',
110111
queueEmpty: 'समीक्षा के लिए कोई लंबित रिक्वेस्ट नहीं है।',
112+
queueWarning: 'समीक्षा कतार अस्थायी रूप से उपलब्ध नहीं है। बाकी वर्कस्पेस सामान्य रूप से काम करता रहेगा।',
111113
queueRequestedBy: 'रिक्वेस्ट',
112114
queuePromote: 'प्रमोट करें',
113115
queueDismiss: 'खारिज करें',
@@ -144,6 +146,7 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
144146
queueTitle: 'Unknown Ingredient Review Queue',
145147
queueHelper: 'New unmatched ingredient requests from cook are collected here for owner review.',
146148
queueEmpty: 'No pending unknown ingredient requests.',
149+
queueWarning: 'Review queue is temporarily unavailable. The rest of the workspace is still usable.',
147150
queueRequestedBy: 'Requested',
148151
queuePromote: 'Promote',
149152
queueDismiss: 'Dismiss',
@@ -412,6 +415,12 @@ export default function Pantry({ inventory, onAddInventoryItem, onUpdateInventor
412415
<h3 className="text-lg font-semibold text-stone-900">{content.queueTitle}</h3>
413416
<p className="text-sm text-stone-500">{content.queueHelper}</p>
414417
</div>
418+
{unknownQueueWarning ? (
419+
<div className="mt-4 flex items-start gap-2 rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
420+
<AlertTriangle size={18} className="mt-0.5 shrink-0" />
421+
<p>{unknownQueueWarning || content.queueWarning}</p>
422+
</div>
423+
) : null}
415424
{openQueueItems.length === 0 ? (
416425
<p className="mt-4 text-sm text-stone-500">{content.queueEmpty}</p>
417426
) : (

src/utils/unknownQueue.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,21 @@ export interface HouseholdMembershipProbeInput {
2020
userUid: string;
2121
}
2222

23+
export type UnknownQueuePermissionDeniedKind =
24+
| 'membership-mismatch'
25+
| 'likely-live-rules-drift'
26+
| 'query-specific-denial'
27+
| 'unknown-permission-denial';
28+
29+
export type UnknownQueueReadProbeResult = 'succeeded' | 'permission-denied' | 'failed' | 'not-run';
30+
2331
export type HouseholdMembershipProbeResult = 'owner' | 'cook' | 'non-member' | 'household-missing';
2432

33+
export interface UnknownQueueLoadFailureClassification {
34+
diagnosticKind: UnknownQueuePermissionDeniedKind | 'index-missing' | 'unknown-load-failure';
35+
userMessage: string;
36+
}
37+
2538
function toRecord(value: unknown): Record<string, unknown> | null {
2639
if (typeof value !== 'object' || value === null) {
2740
return null;
@@ -99,6 +112,54 @@ export function getUnknownQueueLoadErrorMessage(
99112
return 'Failed to load unknown ingredient queue.';
100113
}
101114

115+
export function classifyUnknownQueueLoadFailure(input: {
116+
error: FirestoreListenerErrorInfo;
117+
membershipProbeResult: HouseholdMembershipProbeResult | null;
118+
plainReadProbeResult: UnknownQueueReadProbeResult;
119+
}): UnknownQueueLoadFailureClassification {
120+
const { error, membershipProbeResult, plainReadProbeResult } = input;
121+
122+
if (isFirestorePermissionDeniedError(error)) {
123+
if (membershipProbeResult === 'non-member' || membershipProbeResult === 'household-missing') {
124+
return {
125+
diagnosticKind: 'membership-mismatch',
126+
userMessage: 'Review queue is unavailable for this account.',
127+
};
128+
}
129+
130+
if (plainReadProbeResult === 'succeeded') {
131+
return {
132+
diagnosticKind: 'query-specific-denial',
133+
userMessage: 'Review queue is temporarily unavailable.',
134+
};
135+
}
136+
137+
if (plainReadProbeResult === 'permission-denied') {
138+
return {
139+
diagnosticKind: 'likely-live-rules-drift',
140+
userMessage: 'Review queue is temporarily unavailable.',
141+
};
142+
}
143+
144+
return {
145+
diagnosticKind: 'unknown-permission-denial',
146+
userMessage: 'Review queue is temporarily unavailable.',
147+
};
148+
}
149+
150+
if (isFirestoreFailedPreconditionError(error)) {
151+
return {
152+
diagnosticKind: 'index-missing',
153+
userMessage: 'Review queue order is temporarily unavailable.',
154+
};
155+
}
156+
157+
return {
158+
diagnosticKind: 'unknown-load-failure',
159+
userMessage: 'Review queue is temporarily unavailable.',
160+
};
161+
}
162+
102163
export function sortUnknownIngredientQueueItemsByCreatedAt(items: UnknownIngredientQueueItem[]): UnknownIngredientQueueItem[] {
103164
return [...items].sort((leftItem, rightItem) => {
104165
const rightTime = toTimestampMs(rightItem.createdAt);

0 commit comments

Comments
 (0)